[PAI-1055] Chore: Upgrade Plane Python SDK Version in Plane AI (#5195)

This commit is contained in:
sunder
2026-01-12 19:29:20 +05:30
committed by GitHub
parent 113b1bcb7d
commit 036f56978e
53 changed files with 9744 additions and 5947 deletions
+8 -8
View File
@@ -43,17 +43,17 @@ from pi.services.llm.error_handling import llm_error_handler
from pi.services.llm.llms import get_sql_agent_llm
from pi.services.schemas.chat import QueryFlowStore
from .helpers import _fix_group_by_order_by_mismatch_parsed
from .helpers import _get_available_tables
from .helpers import execute_sql_query
from .helpers import fix_group_by_order_by_mismatch
from .helpers import format_column_context
from .helpers import generate_cte_query
from .helpers import get_column_details
from .helpers import get_table_schemas
from .prompts import TABLE_SELECTION
from .prompts import get_sql_generator
from .schemas import TableSelectionResponse
from .tools import _fix_group_by_order_by_mismatch_parsed
from .tools import _get_available_tables
from .tools import execute_sql_query
from .tools import fix_group_by_order_by_mismatch
from .tools import format_column_context
from .tools import generate_cte_query
from .tools import get_column_details
from .tools import get_table_schemas
log = logger.getChild(__name__)
console = Console()
@@ -161,6 +161,14 @@ def format_column_context(column_context: Dict[str, Any]) -> str:
distinct_values = column["distinct_values_in_column"]
distinct_formatted = ", ".join(f"`{value}`" for value in distinct_values)
formatted_lines.append(f" - **Distinct Values:** {distinct_formatted}")
elif "computed_status_conditions" in column:
conditions = column["computed_status_conditions"]
formatted_lines.append(f" - **Computed Status Conditions:**")
for cond in conditions:
status = cond.get("status", "N/A")
cond_desc = cond.get("description", "No description")
sql_condition = cond.get("sql_condition", "N/A")
formatted_lines.append(f" - **`{status}`** ({cond_desc}): `{sql_condition}`")
formatted_lines.append("")
return "\n".join(formatted_lines)
@@ -1097,6 +1105,31 @@ async def construct_action_entity_url(
else:
return None
elif entity_type == "workspace":
# For workspaces: /workspace_slug/
url = f"{api_base_url}/{workspace_slug}/"
return {"entity_url": url, "entity_name": entity_name, "entity_type": entity_type, "entity_id": entity_id}
elif entity_type == "sticky":
# For stickies: /workspace_slug/stickies/
url = f"{api_base_url}/{workspace_slug}/stickies/"
return {"entity_url": url, "entity_name": entity_name, "entity_type": entity_type, "entity_id": entity_id}
elif entity_type == "initiative":
# For initiatives: /workspace_slug/initiatives/
url = f"{api_base_url}/{workspace_slug}/initiatives/"
return {"entity_url": url, "entity_name": entity_name, "entity_type": entity_type, "entity_id": entity_id}
elif entity_type == "teamspace":
# For teamspaces: /workspace_slug/teamspaces/
url = f"{api_base_url}/{workspace_slug}/teamspaces/"
return {"entity_url": url, "entity_name": entity_name, "entity_type": entity_type, "entity_id": entity_id}
elif entity_type == "customer":
# For customers: /workspace_slug/customers/
url = f"{api_base_url}/{workspace_slug}/customers/"
return {"entity_url": url, "entity_name": entity_name, "entity_type": entity_type, "entity_id": entity_id}
else:
# Unknown entity type
return None
+1 -1
View File
@@ -16,7 +16,7 @@ from typing import Any
# Lazy import to avoid circular dependency
# from pi.services.chat.prompts import plane_context
from pi.agents.sql_agent.tools import format_table_details
from pi.agents.sql_agent.helpers import format_table_details
table_description_content: dict[str, dict[str, Any]] = json.loads(read_text("pi.agents.sql_agent.store", "table-descriptions.json"))
table_descriptions = format_table_details(table_description_content)
@@ -1,245 +1,273 @@
[
{
"table": "intake_issues",
"columns": [
{
"name": "status",
"description": "Enumerated states representing the status of an issue in intake",
"enumerations": [
{
"identifier": -2,
"description": "Pending"
},
{
"identifier": -1,
"description": "Rejected"
},
{
"identifier": 0,
"description": "Snoozed"
},
{
"identifier": 1,
"description": "Accepted"
},
{
"identifier": 2,
"description": "Duplicate"
}
]
}
{
"table": "intake_issues",
"columns": [
{
"name": "status",
"description": "Enumerated states representing the status of an issue in intake",
"enumerations": [
{
"identifier": -2,
"description": "Pending"
},
{
"identifier": -1,
"description": "Rejected"
},
{
"identifier": 0,
"description": "Snoozed"
},
{
"identifier": 1,
"description": "Accepted"
},
{
"identifier": 2,
"description": "Duplicate"
}
]
},
{
"table": "project_members",
"columns": [
{
"name": "role",
"description": "Enumerated role representing the role of a memebr in project",
"enumerations": [
{
"identifier": 5,
"description": "Guest"
},
{
"identifier": 15,
"description": "Member"
},
{
"identifier": 20,
"description": "Admin"
}
]
}
}
]
},
{
"table": "project_members",
"columns": [
{
"name": "role",
"description": "Enumerated role representing the role of a memebr in project",
"enumerations": [
{
"identifier": 5,
"description": "Guest"
},
{
"identifier": 15,
"description": "Member"
},
{
"identifier": 20,
"description": "Admin"
}
]
},
{
"table": "workspace_members",
"columns": [
{
"name": "role",
"description": "Enumerated role representing the role of a memebr in workspace",
"enumerations": [
{
"identifier": 5,
"description": "Guest"
},
{
"identifier": 15,
"description": "Member"
},
{
"identifier": 20,
"description": "Admin"
}
]
}
}
]
},
{
"table": "workspace_members",
"columns": [
{
"name": "role",
"description": "Enumerated role representing the role of a memebr in workspace",
"enumerations": [
{
"identifier": 5,
"description": "Guest"
},
{
"identifier": 15,
"description": "Member"
},
{
"identifier": 20,
"description": "Admin"
}
]
},
{
"table": "issue_relations",
"columns": [
{
"name": "relation_type",
"description": "The type of relationship between two issues.",
"distinct_values_in_column": [
"duplicate",
"finish_before",
"blocked_by",
"start_before",
"relates_to"
]
}
}
]
},
{
"table": "issue_relations",
"columns": [
{
"name": "relation_type",
"description": "The type of relationship between two issues.",
"distinct_values_in_column": ["duplicate", "finish_before", "blocked_by", "start_before", "relates_to"]
}
]
},
{
"table": "issue_activities",
"columns": [
{
"name": "verb",
"description": "The action that was performed on field present in 'field' column for the given issue.",
"distinct_values_in_column": ["created", "deleted", "removed", "updated"]
},
{
"name": "field",
"description": "The name of the field on which activity is performed.",
"distinct_values_in_column": [
"archived_at",
"assignees",
"attachment",
"blocked_by",
"blocking",
"comment",
"cycles",
"description",
"draft",
"duplicate",
"estimate_point",
"finish_before",
"finish_after",
"intake",
"issue",
"labels",
"link",
"modules",
"name",
"parent",
"priority",
"project",
"reaction",
"relates_to",
"sequence_id",
"start_before",
"start_after",
"start_date",
"state",
"target_date",
"type",
"vote"
]
},
{
"table": "issue_activities",
"columns": [
{
"name": "verb",
"description": "The action that was performed on field present in 'field' column for the given issue.",
"distinct_values_in_column": [
"created",
"deleted",
"removed",
"updated"
]
},
{
"name": "field",
"description": "The name of the field on which activity is performed.",
"distinct_values_in_column": [
"archived_at",
"assignees",
"attachment",
"blocked_by",
"blocking",
"comment",
"cycles",
"description",
"draft",
"duplicate",
"estimate_point",
"finish_before",
"finish_after",
"intake",
"issue",
"labels",
"link",
"modules",
"name",
"parent",
"priority",
"project",
"reaction",
"relates_to",
"sequence_id",
"start_before",
"start_after",
"start_date",
"state",
"target_date",
"type",
"vote"
]
},
{
"name": "new_value",
"description": "The new value (string) of the field after the update."
},
{
"name": "old_value",
"description": "The old value (string) of the field before the update."
},
{
"name": "new_identifier",
"description": "The identifier (UUID) of the new value of the field after the update."
},
{
"name": "old_identifier",
"description": "The identifier (UUID) of the old value of the field before the update."
}
},
{
"name": "new_value",
"description": "The new value (string) of the field after the update."
},
{
"name": "old_value",
"description": "The old value (string) of the field before the update."
},
{
"name": "new_identifier",
"description": "The identifier (UUID) of the new value of the field after the update."
},
{
"name": "old_identifier",
"description": "The identifier (UUID) of the old value of the field before the update."
}
]
},
{
"table": "states",
"columns": [
{
"name": "group",
"description": "The name of the group to which the issue state belongs.",
"distinct_values_in_column": ["started", "backlog", "completed", "cancelled", "unstarted"]
}
]
},
{
"table": "issue_attachments",
"columns": [
{
"name": "attributes",
"description": "Attributes of the attachment (size, name).",
"fields_in_jsonb_object": ["name", "size"]
}
]
},
{
"table": "project_states",
"columns": [
{
"name": "group",
"description": "The name of the group to which the project state belongs.",
"distinct_values_in_column": ["monitoring", "draft", "completed", "cancelled", "planning", "execution"]
}
]
},
{
"table": "projects",
"columns": [
{
"name": "network",
"description": "Enumerated numbers representing the visibility of a project (public/ private).",
"enumerations": [
{
"identifier": 0,
"description": "Private"
},
{
"identifier": 2,
"description": "Public"
}
]
},
{
"table": "states",
"columns": [
{
"name": "group",
"description": "The name of the group to which the issue state belongs.",
"distinct_values_in_column": [
"started",
"backlog",
"completed",
"cancelled",
"unstarted"
]
}
}
]
},
{
"table": "issues",
"columns": [
{
"name": "priority",
"description": "The priority level assigned to the issue, stored as lowercase text",
"distinct_values_in_column": ["urgent", "high", "medium", "low", "none"]
}
]
},
{
"table": "cycles",
"columns": [
{
"name": "status",
"description": "IMPORTANT: The 'cycles' table does NOT have a 'status' column. Status is computed dynamically based on 'start_date' and 'end_date'. Use the following conditions to filter by status",
"computed_status_conditions": [
{
"status": "current",
"description": "Currently active cycle",
"sql_condition": "start_date <= NOW() AND end_date >= NOW()"
},
{
"status": "active",
"description": "Alias for 'current' - currently active cycle",
"sql_condition": "start_date <= NOW() AND end_date >= NOW()"
},
{
"status": "ongoing",
"description": "Alias for 'current' - currently active cycle",
"sql_condition": "start_date <= NOW() AND end_date >= NOW()"
},
{
"status": "in-progress",
"description": "Alias for 'current' - currently active cycle",
"sql_condition": "start_date <= NOW() AND end_date >= NOW()"
},
{
"status": "upcoming",
"description": "Cycle that hasn't started yet",
"sql_condition": "start_date > NOW()"
},
{
"status": "completed",
"description": "Cycle that has ended",
"sql_condition": "end_date < NOW()"
},
{
"status": "draft",
"description": "Cycle without start or end dates",
"sql_condition": "start_date IS NULL AND end_date IS NULL"
},
{
"status": "incomplete",
"description": "Cycle that is not yet completed (current, upcoming, or draft)",
"sql_condition": "end_date >= NOW() OR end_date IS NULL"
}
]
},
{
"table": "issue_attachments",
"columns": [
{
"name": "attributes",
"description": "Attributes of the attachment (size, name).",
"fields_in_jsonb_object": [
"name",
"size"
]
}
]
},
{
"table": "project_states",
"columns": [
{
"name": "group",
"description": "The name of the group to which the project state belongs.",
"distinct_values_in_column": [
"monitoring",
"draft",
"completed",
"cancelled",
"planning",
"execution"
]
}
]
},
{
"table": "projects",
"columns": [
{
"name": "network",
"description": "Enumerated numbers representing the visibility of a project (public/ private).",
"enumerations": [
{
"identifier": 0,
"description": "Private"
},
{
"identifier": 2,
"description": "Public"
}
]
}
]
},
{
"table": "issues",
"columns": [
{
"name": "priority",
"description": "The priority level assigned to the issue, stored as lowercase text",
"distinct_values_in_column": [
"urgent",
"high",
"medium",
"low",
"none"
]
}
]
}
]
}
]
},
{
"table": "modules",
"columns": [
{
"name": "status",
"description": "The status of a module, stored as a string",
"distinct_values_in_column": ["backlog", "planned", "in-progress", "paused", "completed", "cancelled"]
}
]
}
]
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -381,6 +381,11 @@ async def get_answer_for_silo_app(data: ChatRequest, request: Request, db: Async
# Add this action's artifact to the list
artifact_data_objects.append(ArtifactData(artifact_id=artifact_id, is_edited=False, action_data=actions_data))
# Type assertions for mypy (validated above)
assert workspace_id is not None
assert chat_id is not None
assert message_id is not None
# Execute batch actions using the service with ALL artifacts
service = BuildModeToolExecutor(chatbot=PlaneChatBot(plane_apps_llm), db=db)
result = await service.execute(
@@ -411,7 +416,7 @@ async def get_answer_for_silo_app(data: ChatRequest, request: Request, db: Async
formatted_context = result
# Parse JSON response for proper API format (avoid double-encoded JSON string)
parsed_response: Union[str, Dict[str, Any]] = final_response
parsed_response: Union[str, Dict[str, Any], Any] = final_response
if response_type == "response" and final_response:
try:
# Try to parse as JSON for structured app responses
@@ -516,6 +521,11 @@ async def get_answer_for_slack(data: ChatRequest, request: Request, db: AsyncSes
# Add this action's artifact to the list
artifact_data_objects.append(ArtifactData(artifact_id=artifact_id, is_edited=False, action_data=actions_data))
# Type assertions for mypy (validated above)
assert workspace_id is not None
assert chat_id is not None
assert message_id is not None
# Execute batch actions using the service with ALL artifacts
service = BuildModeToolExecutor(chatbot=PlaneChatBot(slack_ai_llm), db=db)
result = await service.execute(
@@ -178,6 +178,11 @@ async def create_response_slack(data: ChatRequest, request: Request, db: AsyncSe
artifact_data_objects.append(ArtifactData(artifact_id=artifact_id, is_edited=False, action_data=actions_data))
# Type assertions for mypy (validated above)
assert workspace_id is not None
assert chat_id is not None
assert message_id is not None
# Execute batch actions using the service
service = BuildModeToolExecutor(chatbot=PlaneChatBot("gpt-4.1"), db=db)
+29 -6
View File
@@ -428,6 +428,10 @@ class Settings:
colorlog.getLogger("httpx").setLevel(colorlog.WARNING)
colorlog.getLogger("httpcore").setLevel(colorlog.WARNING)
# Suppress Anthropic client debug logs
colorlog.getLogger("anthropic").setLevel(colorlog.WARNING)
colorlog.getLogger("anthropic._base_client").setLevel(colorlog.WARNING)
# Suppress boto3/botocore debug logs
colorlog.getLogger("boto3").setLevel(colorlog.INFO)
colorlog.getLogger("botocore").setLevel(colorlog.INFO)
@@ -453,13 +457,32 @@ class Settings:
colorlog.getLogger("datadog").setLevel(colorlog.INFO)
colorlog.getLogger("datadog.dogstatsd").setLevel(colorlog.INFO)
colorlog.getLogger("ddtrace.internal.telemetry").setLevel(colorlog.INFO)
colorlog.getLogger("ddtrace.internal.telemetry.writer").setLevel(colorlog.INFO)
# Conditional logging format based on DD_ENABLED (Datadog)
# When Datadog is enabled (production), use JSON logging for structured logs
# When Datadog is disabled (local dev), use colored logs for readability
dd_enabled = get_env_bool("DD_ENABLED", "0")
if dd_enabled:
# Production: Use JSON formatter for structured logging (Datadog)
from pythonjsonlogger.json import JsonFormatter
from pythonjsonlogger.json import JsonFormatter
json_formatter = JsonFormatter(fmt="%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
handler.setFormatter(json_formatter)
colorlog.getLogger("ddtrace.internal.telemetry").setLevel(colorlog.INFO)
colorlog.getLogger("ddtrace.internal.telemetry.writer").setLevel(colorlog.INFO)
json_formatter = JsonFormatter(fmt="%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
handler.setFormatter(json_formatter)
else:
# Local development: Use colorlog for readable output
color_formatter = colorlog.ColoredFormatter(
"%(log_color)s%(asctime)s %(name)-20s %(levelname)-8s%(reset)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
log_colors={
"DEBUG": "cyan",
"INFO": "green",
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "red,bg_white",
},
)
handler.setFormatter(color_formatter)
# Get the root logger and configure it
root_logger = colorlog.getLogger()
@@ -17,8 +17,9 @@ from typing import Optional
from pydantic import BaseModel
# Field list for projects (matching edit modal fields)
# Edit modal shows: name, description, logo_props (emoji/icon), cover_image, network, project_lead
PROJECT_FIELDS = ["name", "description", "logo_props", "cover_image", "network", "project_lead"]
# Edit modal shows: name, description, network, project_lead
# NOTE: logo_props, icon_prop, emoji, cover_image are auto-generated by SDK and should not be included
PROJECT_FIELDS = ["name", "description", "network", "project_lead"]
class ProjectProperties(BaseModel):
@@ -23,6 +23,8 @@ from typing import List
from typing import Optional
from typing import Union
from plane.errors import HttpError # type: ignore[attr-defined]
from .plane_sdk_adapter import PlaneSDKAdapter
from .registry import get_available_categories
from .registry import get_category_methods
@@ -62,6 +64,11 @@ class PlaneActionsExecutor:
self.states = self.sdk_adapter
self.users = self.sdk_adapter
self.workitems = self.sdk_adapter
self.workspaces = self.sdk_adapter
self.initiatives = self.sdk_adapter
self.teamspaces = self.sdk_adapter
self.stickies = self.sdk_adapter
self.customers = self.sdk_adapter
def get_api_categories(self) -> Dict[str, str]:
"""
@@ -105,6 +112,31 @@ class PlaneActionsExecutor:
return {"success": True, "category": category, "method": method, "data": result}
except HttpError as e:
# Centralized handling for auth errors (401/403)
status_code = getattr(e, "status_code", None)
if status_code in (401, 403):
log.warning(f"Auth error ({status_code}) in {category}.{method}: {e}")
return {
"success": False,
"error": "unauthorized",
"message": "Authentication or authorization failed. Please check your credentials and try again.",
"status_code": status_code,
"category": category,
"method": method,
}
# Non-auth HTTP errors
error_msg = f"HTTP error executing {category}.{method}: {str(e)}"
log.error(error_msg)
return {
"success": False,
"category": category,
"method": method,
"error": error_msg,
"error_type": "HttpError",
"status_code": status_code,
}
except Exception as e:
error_msg = f"Error executing {category}.{method}: {str(e)}"
log.error(error_msg)
File diff suppressed because it is too large Load Diff
+199 -13
View File
@@ -30,6 +30,10 @@ API_CATEGORIES: Dict[str, str] = {
"users": "Manage user information and profiles",
"workitems": "Work items/issues - create, update, manage work items",
"intake": "Submit and manage work items in intake queue for triage",
"initiatives": "Manage strategic initiatives with epics, labels, and projects",
"teamspaces": "Manage team collaboration spaces with members and projects",
"stickies": "Create and manage quick sticky notes and annotations",
"customers": "Manage customer relationships and information",
"members": "Manage workspace and project members",
"activity": "Track and manage work item activities",
"attachments": "Manage file attachments on work items",
@@ -38,6 +42,7 @@ API_CATEGORIES: Dict[str, str] = {
"properties": "Manage custom properties and their values",
"types": "Manage work item types",
"worklogs": "Track and manage time entries",
"workspaces": "Manage workspace settings and features",
}
# Centralized registry of methods for each category
@@ -45,12 +50,11 @@ API_METHODS: Dict[str, Dict[str, str]] = {
"workitems": {
"create": "Create a new work item/issue",
"update": "Update an existing work item/issue",
"create_relation": "Create relationships between work items (blocking, blocked_by, duplicate, etc.)",
# "list": "List work items with filtering",
# "retrieve": "Get a single work item by ID",
"retrieve": "Get details of a single work item",
"list": "List work items in a project",
"delete": "Delete a work item",
# "search": "Search work items by criteria",
# "get_workspace": "Get work item across workspace",
"create_relation": "Create a relationship between work items (blocks, relates_to, etc.)",
"search": "Search work items across workspace by query string",
},
"projects": {
"create": "Create a new project",
@@ -58,8 +62,8 @@ API_METHODS: Dict[str, Dict[str, str]] = {
"retrieve": "Get details of a single project",
"update": "Update project details",
"delete": "Delete a project",
"archive": "Archive a project",
"unarchive": "Restore an archived project",
"get_features": "Get enabled project features (epics, cycles, modules, etc.)",
"update_features": "Enable or disable project features",
},
"cycles": {
"create": "Create a new cycle",
@@ -117,6 +121,53 @@ API_METHODS: Dict[str, Dict[str, str]] = {
"update": "Update intake work item details",
"delete": "Remove intake work item",
},
"initiatives": {
"create": "Create a new initiative",
"list": "List initiatives in workspace",
"retrieve": "Get single initiative by ID",
"update": "Update initiative details",
"delete": "Delete an initiative",
"create_label": "Create initiative label",
"list_labels": "List initiative labels",
"retrieve_label": "Get initiative label by ID",
"update_label": "Update initiative label",
"delete_label": "Delete initiative label",
"add_labels": "Add labels to initiative",
"remove_labels": "Remove labels from initiative",
"add_projects": "Link projects to initiative",
"list_projects": "List initiative's projects",
"remove_projects": "Unlink projects from initiative",
"add_epics": "Link epics to initiative",
"list_epics": "List initiative's epics",
"remove_epics": "Unlink epics from initiative",
},
"teamspaces": {
"create": "Create a new teamspace",
"list": "List teamspaces in workspace",
"retrieve": "Get single teamspace by ID",
"update": "Update teamspace details",
"delete": "Delete a teamspace",
"add_members": "Add members to teamspace",
"list_members": "List teamspace members",
"remove_members": "Remove members from teamspace",
"add_projects": "Add projects to teamspace",
"list_projects": "List teamspace projects",
"remove_projects": "Remove projects from teamspace",
},
"stickies": {
"create": "Create a new sticky note",
"list": "List stickies in workspace",
"retrieve": "Get single sticky by ID",
"update": "Update sticky details",
"delete": "Delete a sticky",
},
"customers": {
"create": "Create a new customer",
"list": "List customers in workspace",
"retrieve": "Get single customer by ID",
"update": "Update customer details",
"delete": "Delete a customer",
},
"members": {
"get_workspace_members": "List all workspace members",
"get_project_members": "List all project members",
@@ -173,6 +224,10 @@ API_METHODS: Dict[str, Dict[str, str]] = {
"update": "Update time entry",
"delete": "Delete time entry",
},
"workspaces": {
"get_features": "Get enabled workspace features (initiatives, teams, customers, etc.)",
"update_features": "Enable or disable workspace features",
},
}
@@ -201,18 +256,19 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = {
"create_relation": "create_work_item_relation",
"list": "list_work_items",
"retrieve": "retrieve_work_item",
"search": "search_work_items",
"get_workspace": "get_workspace_work_item",
"delete": "delete_work_item",
"search": "search_work_items",
"create_epic": "create_work_item", # Epic is a workitem with specific type_id
"update_epic": "update_work_item", # Epic is a workitem with specific type_id
},
"projects": {
"create": "create_project",
"list": "list_projects",
"retrieve": "retrieve_project",
"update": "update_project",
"archive": "archive_project",
"unarchive": "unarchive_project",
"delete": "delete_project",
"get_features": "get_project_features",
"update_features": "update_project_features",
},
"cycles": {
"create": "create_cycle",
@@ -224,7 +280,6 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = {
"list_archived": "list_archived_cycles",
"add_work_items": "add_cycle_work_items",
"list_work_items": "list_cycle_work_items",
"retrieve_work_item": "retrieve_cycle_work_item",
"remove_work_item": "remove_cycle_work_item",
"transfer_work_items": "transfer_cycle_work_items",
"delete": "delete_cycle",
@@ -232,7 +287,7 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = {
"labels": {
"create": "create_label",
"list": "list_labels",
"retrieve": "get_labels",
"retrieve": "retrieve_label",
"update": "update_label",
"delete": "delete_label",
},
@@ -259,6 +314,8 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = {
"pages": {
"create_project_page": "create_project_page",
"create_workspace_page": "create_workspace_page",
"retrieve_project": "retrieve_project_page",
"retrieve_workspace": "retrieve_workspace_page",
},
"assets": {
"create": "create_generic_asset_upload",
@@ -279,6 +336,53 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = {
"update": "update_intake_work_item",
"delete": "delete_intake_work_item",
},
"initiatives": {
"create": "create_initiative",
"list": "list_initiatives",
"retrieve": "retrieve_initiative",
"update": "update_initiative",
"delete": "delete_initiative",
"create_label": "create_initiative_label",
"list_labels": "list_initiative_labels",
"retrieve_label": "retrieve_initiative_label",
"update_label": "update_initiative_label",
"delete_label": "delete_initiative_label",
"add_labels": "add_initiative_labels",
"remove_labels": "remove_initiative_labels",
"add_projects": "add_initiative_projects",
"list_projects": "list_initiative_projects",
"remove_projects": "remove_initiative_projects",
"add_epics": "add_initiative_epics",
"list_epics": "list_initiative_epics",
"remove_epics": "remove_initiative_epics",
},
"teamspaces": {
"create": "create_teamspace",
"list": "list_teamspaces",
"retrieve": "retrieve_teamspace",
"update": "update_teamspace",
"delete": "delete_teamspace",
"add_members": "add_teamspace_members",
"list_members": "list_teamspace_members",
"remove_members": "remove_teamspace_members",
"add_projects": "add_teamspace_projects",
"list_projects": "list_teamspace_projects",
"remove_projects": "remove_teamspace_projects",
},
"stickies": {
"create": "create_sticky",
"list": "list_stickies",
"retrieve": "retrieve_sticky",
"update": "update_sticky",
"delete": "delete_sticky",
},
"customers": {
"create": "create_customer",
"list": "list_customers",
"retrieve": "retrieve_customer",
"update": "update_customer",
"delete": "delete_customer",
},
"members": {
"get_workspace_members": "get_workspace_members",
"get_project_members": "get_project_members",
@@ -291,6 +395,7 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = {
"create": "create_work_item_attachment",
"list": "list_work_item_attachments",
"retrieve": "retrieve_work_item_attachment",
"update": "update_work_item_attachment",
"delete": "delete_work_item_attachment",
},
"comments": {
@@ -335,6 +440,10 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = {
"update": "update_issue_worklog",
"delete": "delete_issue_worklog",
},
"workspaces": {
"get_features": "get_workspace_features",
"update_features": "update_workspace_features",
},
}
@@ -371,3 +480,80 @@ def resolve_actual_method_name(category: str, method: str) -> Optional[str]:
# If not a simplified name, assume it could already be actual; allow pass-through
return method
# ============================================================================
# CENTRALIZED TOOL DEFINITIONS (Phase 1: Foundation)
# ============================================================================
# This is the single source of truth for all tool metadata.
# Auto-generation in progress - currently only labels category defined.
from pi.services.actions.tool_metadata import ToolMetadata
# Tool definitions - now imported from individual tool modules for modularity
# Each tool file defines its own metadata, special handlers, and business logic
from pi.services.actions.tools.activity import ACTIVITY_TOOL_DEFINITIONS
from pi.services.actions.tools.assets import ASSET_TOOL_DEFINITIONS
from pi.services.actions.tools.comments import COMMENTS_TOOL_DEFINITIONS
from pi.services.actions.tools.customers import CUSTOMER_TOOL_DEFINITIONS
from pi.services.actions.tools.cycles import CYCLES_TOOL_DEFINITIONS
from pi.services.actions.tools.initiatives import INITIATIVE_TOOL_DEFINITIONS
from pi.services.actions.tools.intake import INTAKE_TOOL_DEFINITIONS
from pi.services.actions.tools.labels import LABEL_TOOL_DEFINITIONS
from pi.services.actions.tools.links import LINKS_TOOL_DEFINITIONS
from pi.services.actions.tools.members import MEMBER_TOOL_DEFINITIONS
from pi.services.actions.tools.modules import MODULES_TOOL_DEFINITIONS
from pi.services.actions.tools.pages import PAGE_TOOL_DEFINITIONS
from pi.services.actions.tools.projects import PROJECT_TOOL_DEFINITIONS
from pi.services.actions.tools.properties import PROPERTIES_TOOL_DEFINITIONS
from pi.services.actions.tools.states import STATE_TOOL_DEFINITIONS
from pi.services.actions.tools.stickies import STICKY_TOOL_DEFINITIONS
from pi.services.actions.tools.teamspaces import TEAMSPACE_TOOL_DEFINITIONS
from pi.services.actions.tools.types import TYPE_TOOL_DEFINITIONS
from pi.services.actions.tools.users import USER_TOOL_DEFINITIONS
from pi.services.actions.tools.workitems import WORKITEMS_TOOL_DEFINITIONS
from pi.services.actions.tools.worklogs import WORKLOGS_TOOL_DEFINITIONS
from pi.services.actions.tools.workspaces import WORKSPACE_TOOL_DEFINITIONS
TOOL_DEFINITIONS: Dict[str, Dict[str, ToolMetadata]] = {
"activity": ACTIVITY_TOOL_DEFINITIONS,
"assets": ASSET_TOOL_DEFINITIONS,
"comments": COMMENTS_TOOL_DEFINITIONS,
"customers": CUSTOMER_TOOL_DEFINITIONS,
"cycles": CYCLES_TOOL_DEFINITIONS,
"initiatives": INITIATIVE_TOOL_DEFINITIONS,
"intake": INTAKE_TOOL_DEFINITIONS,
"labels": LABEL_TOOL_DEFINITIONS,
"links": LINKS_TOOL_DEFINITIONS,
"members": MEMBER_TOOL_DEFINITIONS,
"modules": MODULES_TOOL_DEFINITIONS,
"pages": PAGE_TOOL_DEFINITIONS,
"projects": PROJECT_TOOL_DEFINITIONS,
"properties": PROPERTIES_TOOL_DEFINITIONS,
"states": STATE_TOOL_DEFINITIONS,
"stickies": STICKY_TOOL_DEFINITIONS,
"teamspaces": TEAMSPACE_TOOL_DEFINITIONS,
"types": TYPE_TOOL_DEFINITIONS,
"users": USER_TOOL_DEFINITIONS,
"workitems": WORKITEMS_TOOL_DEFINITIONS,
"worklogs": WORKLOGS_TOOL_DEFINITIONS,
"workspaces": WORKSPACE_TOOL_DEFINITIONS,
}
def get_tool_metadata(category: str, method: str) -> Optional[ToolMetadata]:
"""Get tool metadata for a specific category and method.
Args:
category: API category (e.g., "labels", "workitems")
method: Simplified method name (e.g., "create", "list")
Returns:
ToolMetadata if found, None otherwise
"""
return TOOL_DEFINITIONS.get(category, {}).get(method)
def get_all_tool_metadata() -> Dict[str, Dict[str, ToolMetadata]]:
"""Get all tool metadata across all categories."""
return TOOL_DEFINITIONS.copy()
@@ -0,0 +1,315 @@
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
#
# Licensed under the Plane Commercial License (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://plane.so/legals/eula
#
# DO NOT remove or modify this notice.
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
"""
Tool generator for auto-generating LangChain tools from centralized metadata.
This module provides functionality to dynamically create LangChain tools
from ToolMetadata definitions, eliminating manual tool duplication.
"""
import inspect
from typing import Any
from typing import Callable
from typing import Dict
from typing import List
from langchain_core.tools import tool
from pi import logger
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tools.base import PlaneToolBase
log = logger.getChild(__name__)
# Complete type mapping - no string parsing needed
from typing import Optional
_TYPE_MAP = {
# Basic types
"str": str,
"int": int,
"float": float,
"bool": bool,
"dict": dict,
"list": list,
# Optional types
"Optional[str]": Optional[str],
"Optional[int]": Optional[int],
"Optional[float]": Optional[float],
"Optional[bool]": Optional[bool],
"Optional[dict]": Optional[dict],
"Optional[list]": Optional[list],
# Container types
"List[str]": list,
"List[int]": list,
"List[dict]": list,
"Dict[str, Any]": dict,
"Dict[str, str]": dict,
}
def _parse_type_annotation(type_str: str) -> Any:
"""Parse type annotation string into actual Python type.
Args:
type_str: Type as string (e.g., "str", "Optional[str]", "List[str]")
Returns:
Python type object or typing generic
"""
return _TYPE_MAP.get(type_str, str)
def _build_function_signature(metadata: ToolMetadata) -> Dict[str, inspect.Parameter]:
"""Build function signature from metadata parameters.
Args:
metadata: Tool metadata
Returns:
Dictionary of parameter name to inspect.Parameter objects
"""
sig_params = {}
for param in metadata.parameters:
# Determine default value
if param.required:
default = inspect.Parameter.empty
else:
default = param.default # type: ignore[assignment]
# Create parameter
sig_params[param.name] = inspect.Parameter(
name=param.name,
kind=inspect.Parameter.KEYWORD_ONLY,
default=default,
annotation=_parse_type_annotation(param.type),
)
return sig_params
def _build_type_annotations(metadata: ToolMetadata) -> Dict[str, Any]:
"""Build type annotations dictionary for function.
Args:
metadata: Tool metadata
Returns:
Dictionary of parameter names to types
"""
annotations = {}
for param in metadata.parameters:
annotations[param.name] = _parse_type_annotation(param.type)
# Add return type
annotations["return"] = Dict[str, Any]
return annotations
def _build_docstring(metadata: ToolMetadata) -> str:
"""Generate Google-style docstring from metadata.
Args:
metadata: Tool metadata
Returns:
Formatted docstring
"""
lines = [metadata.description, ""]
if metadata.parameters:
lines.append("Args:")
for param in metadata.parameters:
# Format: param_name: Description
required_marker = "(required)" if param.required else ""
lines.append(f" {param.name}: {param.description} {required_marker}".strip())
lines.append("")
return "\n".join(lines)
async def _apply_pre_processing(
metadata: ToolMetadata,
kwargs: Dict[str, Any],
context: Dict[str, Any],
category: str,
method_key: str,
method_executor: Any = None,
) -> Dict[str, Any]:
"""Apply pre-processing via custom handler if provided.
Args:
metadata: Tool metadata
kwargs: Tool arguments
context: Execution context
category: API category
method_key: Method key
method_executor: Method executor instance (for making additional API calls)
Returns:
Modified kwargs
"""
if metadata.pre_handler:
return await metadata.pre_handler(metadata, kwargs, context, category, method_key, method_executor)
return kwargs
async def _apply_post_processing(
metadata: ToolMetadata,
result: Dict[str, Any],
kwargs: Dict[str, Any],
context: Dict[str, Any],
method_executor: Any,
category: str,
method_key: str,
) -> Dict[str, Any]:
"""Apply post-processing via custom handler if provided.
Args:
metadata: Tool metadata
result: Execution result
kwargs: Original tool arguments
context: Execution context
method_executor: Method executor instance
category: API category
method_key: Method key
Returns:
Modified result
"""
if metadata.post_handler:
return await metadata.post_handler(metadata, result, kwargs, context, method_executor, category, method_key)
return result
def generate_tool_from_metadata(
category: str,
method_key: str,
metadata: ToolMetadata,
method_executor: Any,
context: Dict[str, Any],
) -> Callable:
"""Generate a LangChain tool function from metadata.
This function creates a fully functional LangChain tool that:
- Has the correct signature and type annotations
- Auto-fills context parameters (workspace_slug, project_id)
- Calls method_executor.execute() with the right category/method
- Formats responses using PlaneToolBase helpers
Args:
category: API category (e.g., "labels")
method_key: Simplified method name (e.g., "create")
metadata: Complete tool metadata
method_executor: MethodExecutor instance for calling SDK adapter
context: Execution context with workspace_slug, project_id, etc.
Returns:
Decorated LangChain tool function
"""
# Create async function dynamically
async def tool_func(**kwargs):
"""Dynamically generated tool function."""
# Auto-fill context values for parameters marked as auto_fill_from_context
for param in metadata.parameters:
if param.auto_fill_from_context and param.name in context:
if param.name not in kwargs or kwargs[param.name] is None:
kwargs[param.name] = context[param.name]
log.debug(f"Auto-filled {param.name} = {context[param.name]} for {metadata.name}")
# PRE-PROCESSING: Apply custom handler if provided
if metadata.pre_handler:
try:
kwargs = await _apply_pre_processing(metadata, kwargs, context, category, method_key, method_executor)
except ValueError as e:
# Pre-processing validation failed
return PlaneToolBase.format_error_payload(str(e), "")
# Execute via method_executor
result = await method_executor.execute(category, method_key, **kwargs)
# POST-PROCESSING: Apply custom handler if provided
if metadata.post_handler:
result = await _apply_post_processing(metadata, result, kwargs, context, method_executor, category, method_key)
# Format response based on whether it returns an entity
if result["success"]:
if metadata.returns_entity_type:
# Format with entity URL
return await PlaneToolBase.format_success_payload_with_url(
f"Successfully executed {metadata.name}", result.get("data"), metadata.returns_entity_type, context
)
else:
# Simple success payload
return PlaneToolBase.format_success_payload("Action successfully executed", result["data"])
else:
# Error payload
return PlaneToolBase.format_error_payload(f"Failed to execute {metadata.name}", result["error"])
# Set function metadata
tool_func.__name__ = metadata.name
tool_func.__doc__ = _build_docstring(metadata)
tool_func.__annotations__ = _build_type_annotations(metadata)
# Build signature (for inspect.signature compatibility)
sig_params = _build_function_signature(metadata)
tool_func.__signature__ = inspect.Signature(parameters=list(sig_params.values())) # type: ignore[attr-defined]
# Apply @tool decorator
decorated_tool = tool(tool_func)
log.info(f"Generated tool: {metadata.name} (category={category}, method={method_key})")
return decorated_tool # type: ignore[return-value]
def generate_tools_for_category(
category: str,
method_executor: Any,
context: Dict[str, Any],
tool_definitions: Dict[str, ToolMetadata],
) -> List[Callable]:
"""Generate all tools for a category from metadata.
Args:
category: API category (e.g., "labels")
method_executor: MethodExecutor instance
context: Execution context
tool_definitions: Dictionary of method_key -> ToolMetadata for this category
Returns:
List of generated LangChain tools
"""
tools = []
for method_key, metadata in tool_definitions.items():
try:
tool_func = generate_tool_from_metadata(
category=category,
method_key=method_key,
metadata=metadata,
method_executor=method_executor,
context=context,
)
tools.append(tool_func)
except Exception as e:
log.error(f"Error generating tool for {category}.{method_key}: {e}", exc_info=True)
# Continue generating other tools
log.info(f"Generated {len(tools)} tools for category '{category}'")
return tools
@@ -0,0 +1,92 @@
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
#
# Licensed under the Plane Commercial License (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://plane.so/legals/eula
#
# DO NOT remove or modify this notice.
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
"""
Tool metadata models for centralized tool definition and auto-generation.
This module defines Pydantic models that serve as the single source of truth
for all LangChain tool definitions. Tool metadata includes:
- Tool name and description
- All parameters with types, descriptions, and defaults
- SDK adapter method mapping
- Entity type for URL construction
"""
from typing import Any
from typing import List
from typing import Literal
from typing import Optional
from pydantic import BaseModel
class ToolParameter(BaseModel):
"""Metadata for a single tool parameter.
Attributes:
name: Parameter name (e.g., "project_id", "color", "assignees")
type: Python type annotation as string (e.g., "str", "Optional[str]", "List[str]")
required: Whether parameter is required
description: Human-readable parameter description for LLM
default: Default value if parameter is optional
auto_fill_from_context: Whether to auto-fill from context (workspace_slug, project_id)
property_transform: How to transform for UI property mapper (Phase 2 feature)
"""
name: str
type: str
required: bool = True
description: str
default: Optional[Any] = None
auto_fill_from_context: bool = False
property_transform: Literal["name", "id", "id_list", "bool", "date", "json", "int", "skip"] = "name"
class Config:
# Allow extra fields for future extensions
extra = "allow"
class ToolMetadata(BaseModel):
"""Metadata for a single tool definition.
This serves as the single source of truth for tool configuration,
used to auto-generate LangChain tools and UI property mappings.
"""
name: str
description: str
sdk_method: str
parameters: List[ToolParameter] = []
returns_entity_type: Optional[str] = None
pre_handler: Optional[Any] = None # Custom async function(metadata, kwargs, context, category, method_key) -> kwargs
post_handler: Optional[Any] = None # Custom async function(metadata, result, kwargs, context, method_executor, category, method_key) -> result
class Config:
arbitrary_types_allowed = True # Allow function types
def get_parameter(self, name: str) -> Optional[ToolParameter]:
"""Get parameter by name."""
for param in self.parameters:
if param.name == name:
return param
return None
def get_required_parameters(self) -> List[ToolParameter]:
"""Get all required parameters."""
return [p for p in self.parameters if p.required]
def get_optional_parameters(self) -> List[ToolParameter]:
"""Get all optional parameters."""
return [p for p in self.parameters if not p.required]
def get_auto_fill_parameters(self) -> List[ToolParameter]:
"""Get parameters that should be auto-filled from context."""
return [p for p in self.parameters if p.auto_fill_from_context]
@@ -29,7 +29,9 @@ from .activity import get_activity_tools
from .assets import get_asset_tools
from .attachments import get_attachment_tools
from .comments import get_comment_tools
from .customers import get_customer_tools
from .cycles import get_cycle_tools
from .initiatives import get_initiative_tools
from .intake import get_intake_tools
from .labels import get_label_tools
from .links import get_link_tools
@@ -39,10 +41,13 @@ from .pages import get_page_tools
from .projects import get_project_tools
from .properties import get_property_tools
from .states import get_state_tools
from .stickies import get_sticky_tools
from .teamspaces import get_teamspace_tools
from .types import get_type_tools
from .users import get_user_tools
from .workitems import get_workitem_tools
from .worklogs import get_worklog_tools
from .workspaces import get_workspace_tools
CATEGORY_TO_PROVIDER: Dict[str, Callable] = {
"activity": get_activity_tools,
@@ -53,10 +58,14 @@ CATEGORY_TO_PROVIDER: Dict[str, Callable] = {
"attachment": get_attachment_tools,
"comments": get_comment_tools,
"comment": get_comment_tools,
"customers": get_customer_tools,
"customer": get_customer_tools,
"cycles": get_cycle_tools,
"cycle": get_cycle_tools,
"intake": get_intake_tools,
"intakes": get_intake_tools,
"initiatives": get_initiative_tools,
"initiative": get_initiative_tools,
"labels": get_label_tools,
"label": get_label_tools,
"links": get_link_tools,
@@ -73,6 +82,10 @@ CATEGORY_TO_PROVIDER: Dict[str, Callable] = {
"property": get_property_tools,
"states": get_state_tools,
"state": get_state_tools,
"stickies": get_sticky_tools,
"sticky": get_sticky_tools,
"teamspaces": get_teamspace_tools,
"teamspace": get_teamspace_tools,
"types": get_type_tools,
"type": get_type_tools,
"users": get_user_tools,
@@ -81,6 +94,8 @@ CATEGORY_TO_PROVIDER: Dict[str, Callable] = {
"workitem": get_workitem_tools,
"worklogs": get_worklog_tools,
"worklog": get_worklog_tools,
"workspaces": get_workspace_tools,
"workspace": get_workspace_tools,
}
+123 -53
View File
@@ -15,62 +15,132 @@ Activity API tools for Plane activity tracking operations.
from typing import Any
from typing import Dict
from typing import Optional
from langchain_core.tools import tool
from pi import logger
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
from .base import PlaneToolBase
# ============================================================================
# ACTIVITY-SPECIFIC HANDLER FUNCTIONS
# ============================================================================
log = logger.getChild("activity")
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing activity actions
async def _activity_pre_handler(
metadata: ToolMetadata,
kwargs: Dict[str, Any],
context: Dict[str, Any],
category: str,
method_key: str,
method_executor: Any = None,
) -> Dict[str, Any]:
"""Pre-processing handler for activity tools.
Handles:
- Auto-detect project_id from issue_id if missing
"""
# If project_id is missing but we have issue_id, look it up in the database
if kwargs.get("issue_id") and not kwargs.get("project_id"):
issue_id = kwargs["issue_id"]
log.debug(f"project_id missing for activity_{method_key}, looking up issue_id={issue_id}")
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
work_item = await get_issue_identifier_for_artifact(issue_id)
if work_item and work_item.get("project_id"):
kwargs["project_id"] = work_item["project_id"]
log.debug(f"Auto-filled project_id={kwargs["project_id"]} from work item lookup")
else:
log.warning(f"Could not find project_id for issue_id={issue_id}")
except Exception as e:
log.error(f"Failed to lookup work item for project_id: {e}")
# Don't fail the entire operation, let the SDK call fail with proper error
return kwargs
# ============================================================================
# ACTIVITY TOOL DEFINITIONS
# ============================================================================
ACTIVITY_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"list": ToolMetadata(
name="activity_list",
description="List activity for a specific work item",
sdk_method="list_work_item_activities",
pre_handler=_activity_pre_handler,
parameters=[
ToolParameter(
name="issue_id",
type="str",
required=True,
description="Work item ID (required) - activities are specific to individual work items",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"retrieve": ToolMetadata(
name="activity_retrieve",
description="Get a single activity by ID",
sdk_method="retrieve_work_item_activity",
pre_handler=_activity_pre_handler,
parameters=[
ToolParameter(
name="activity_id",
type="str",
required=True,
description="Activity ID (required)",
),
ToolParameter(
name="issue_id",
type="str",
required=True,
description="Work item ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
}
# ============================================================================
# TOOL FACTORY
# ============================================================================
def get_activity_tools(method_executor, context):
"""Return LangChain tools for the activity category using method_executor and context."""
@tool
async def activity_list(project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""List activity for a project.
Args:
project_id: Parameter description (optional)
workspace_slug: Parameter description (optional)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute("activity", "list", project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved activity list", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list activity", result["error"])
@tool
async def activity_retrieve(
activity_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Get a single activity by ID."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"activity",
"retrieve",
activity_id=activity_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved activity", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to retrieve activity", result["error"])
return [activity_list, activity_retrieve]
"""Return LangChain tools for the activity category using auto-generation from metadata."""
return generate_tools_for_category(
category="activity",
method_executor=method_executor,
context=context,
tool_definitions=ACTIVITY_TOOL_DEFINITIONS,
)
+150 -118
View File
@@ -13,128 +13,160 @@
Assets API tools for Plane asset management operations.
"""
from typing import Any
from typing import Dict
from typing import Optional
from langchain_core.tools import tool
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
from .base import PlaneToolBase
# ============================================================================
# ASSETS TOOL DEFINITIONS
# ============================================================================
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing asset actions
ASSET_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"create": ToolMetadata(
name="assets_create",
description="Create a new generic asset",
sdk_method="create_generic_asset_upload",
parameters=[
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"create_user_upload": ToolMetadata(
name="assets_create_user_upload",
description="Upload user-specific assets",
sdk_method="create_user_asset_upload",
parameters=[
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"get_generic": ToolMetadata(
name="assets_get_generic",
description="Retrieve generic assets",
sdk_method="get_generic_asset",
parameters=[
ToolParameter(name="asset_id", type="str", required=True, description="Asset ID (required)"),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"update_generic": ToolMetadata(
name="assets_update_generic",
description="Update generic assets",
sdk_method="update_generic_asset",
parameters=[
ToolParameter(name="asset_id", type="str", required=True, description="Asset ID (required)"),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"update_user": ToolMetadata(
name="assets_update_user",
description="Update user assets",
sdk_method="update_user_asset",
parameters=[
ToolParameter(name="asset_id", type="str", required=True, description="Asset ID (required)"),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"delete_user": ToolMetadata(
name="assets_delete_user",
description="Delete user assets",
sdk_method="delete_user_asset",
parameters=[
ToolParameter(name="asset_id", type="str", required=True, description="Asset ID (required)"),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
}
# ============================================================================
# TOOL FACTORY
# ============================================================================
def get_asset_tools(method_executor, context):
"""Return LangChain tools for the assets category using method_executor and context."""
@tool
async def assets_create(project_id: Optional[str] = None, workspace_slug: Optional[str] = None, **kwargs) -> Dict[str, Any]:
"""Create a new generic asset.
Args:
project_id: Parameter description (optional)
workspace_slug: Parameter description (optional)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute("assets", "create", project_id=project_id, workspace_slug=workspace_slug, **kwargs)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully created asset", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to create asset", result["error"])
@tool
async def assets_create_user_upload(project_id: Optional[str] = None, workspace_slug: Optional[str] = None, **kwargs) -> Dict[str, Any]:
"""Upload user-specific assets."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute("assets", "create_user_upload", project_id=project_id, workspace_slug=workspace_slug, **kwargs)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully created user asset upload", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to create user asset upload", result["error"])
@tool
async def assets_get_generic(
asset_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Retrieve generic assets."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute("assets", "get_generic", asset_id=asset_id, project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved generic asset", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to get generic asset", result["error"])
@tool
async def assets_update_generic(
asset_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, **kwargs
) -> Dict[str, Any]:
"""Update generic assets."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"assets", "update_generic", asset_id=asset_id, project_id=project_id, workspace_slug=workspace_slug, **kwargs
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully updated generic asset", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to update generic asset", result["error"])
@tool
async def assets_update_user(asset_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, **kwargs) -> Dict[str, Any]:
"""Update user assets."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"assets", "update_user", asset_id=asset_id, project_id=project_id, workspace_slug=workspace_slug, **kwargs
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully updated user asset", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to update user asset", result["error"])
@tool
async def assets_delete_user(
asset_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Delete user assets."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute("assets", "delete_user", asset_id=asset_id, project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully deleted user asset", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to delete user asset", result["error"])
return [assets_create, assets_create_user_upload, assets_get_generic, assets_update_generic, assets_update_user, assets_delete_user]
"""Return LangChain tools for the assets category using auto-generation from metadata."""
return generate_tools_for_category(
category="assets",
method_executor=method_executor,
context=context,
tool_definitions=ASSET_TOOL_DEFINITIONS,
)
+4 -4
View File
@@ -88,7 +88,7 @@ class PlaneToolBase:
"""
# Lazy import to avoid circular dependency
from pi.agents.sql_agent.tools import extract_entity_from_api_response
from pi.agents.sql_agent.helpers import extract_entity_from_api_response
# Extract entity data from response
entity_data = extract_entity_from_api_response(data, entity_type)
@@ -107,7 +107,7 @@ class PlaneToolBase:
# Construct entity URL
try:
# Lazy import to avoid circular dependency
from pi.agents.sql_agent.tools import construct_action_entity_url
from pi.agents.sql_agent.helpers import construct_action_entity_url
# Since this method is now async, we can directly await the async function
url_info = await construct_action_entity_url(entity_data, entity_type, workspace_slug, frontend_url)
@@ -164,7 +164,7 @@ class PlaneToolBase:
Structured success payload that also includes constructed entity URL information when available.
"""
# Lazy import to avoid circular dependency
from pi.agents.sql_agent.tools import extract_entity_from_api_response
from pi.agents.sql_agent.helpers import extract_entity_from_api_response
from pi.config import settings as _settings
entity: Dict[str, Any] = {}
@@ -177,7 +177,7 @@ class PlaneToolBase:
entity_data["workspace"] = str(context["workspace_id"])
if workspace_slug:
try:
from pi.agents.sql_agent.tools import construct_action_entity_url
from pi.agents.sql_agent.helpers import construct_action_entity_url
url_info = await construct_action_entity_url(entity_data, entity_type, workspace_slug, frontend_url)
if url_info:
+148 -280
View File
@@ -16,291 +16,159 @@ Comments API tools for Plane issue comments operations.
import uuid
from typing import Any
from typing import Dict
from typing import Optional
from langchain_core.tools import tool
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
from pi.config import settings
from .base import PlaneToolBase
# Pre-handler to resolve project_id from issue_id if missing
async def _resolve_project_pre_handler(
metadata: ToolMetadata,
kwargs: Dict[str, Any],
context: Dict[str, Any],
category: str,
method_key: str,
method_executor: Any,
) -> Dict[str, Any]:
"""Resolve project_id from issue_id if it is missing or invalid."""
issue_id = kwargs.get("issue_id")
project_id = kwargs.get("project_id")
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing comment actions
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
kwargs["project_id"] = issue_info["project_id"]
except Exception:
# Best effort, proceed without resolution if it fails
pass
return kwargs
COMMENTS_TOOL_DEFINITIONS = {
"create": ToolMetadata(
name="comments_create",
description="Create a comment on an issue.",
sdk_method="create_work_item_comment",
parameters=[
ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"),
ToolParameter(name="comment_html", description="Comment content in HTML format", required=True, type="str"),
ToolParameter(
name="project_id", description="Project ID (optional, auto-filled)", required=False, type="Optional[str]", auto_fill_from_context=True
),
ToolParameter(
name="workspace_slug",
description="Workspace slug (optional, auto-filled)",
required=False,
type="Optional[str]",
auto_fill_from_context=True,
),
ToolParameter(name="external_source", description="External source identifier (e.g., 'jira')", required=False, type="Optional[str]"),
ToolParameter(name="external_id", description="External system ID", required=False, type="Optional[str]"),
],
pre_handler=_resolve_project_pre_handler,
returns_entity_type="comment",
),
"list": ToolMetadata(
name="comments_list",
description="List comments for an issue.",
sdk_method="list_work_item_comments",
parameters=[
ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"),
ToolParameter(
name="project_id", description="Project ID (optional, auto-filled)", required=False, type="Optional[str]", auto_fill_from_context=True
),
ToolParameter(
name="workspace_slug",
description="Workspace slug (optional, auto-filled)",
required=False,
type="Optional[str]",
auto_fill_from_context=True,
),
],
pre_handler=_resolve_project_pre_handler,
),
"retrieve": ToolMetadata(
name="comments_retrieve",
description="Get a single comment by ID.",
sdk_method="retrieve_work_item_comment",
parameters=[
ToolParameter(name="comment_id", description="Comment ID", required=True, type="str"),
ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"),
ToolParameter(
name="project_id", description="Project ID (optional, auto-filled)", required=False, type="Optional[str]", auto_fill_from_context=True
),
ToolParameter(
name="workspace_slug",
description="Workspace slug (optional, auto-filled)",
required=False,
type="Optional[str]",
auto_fill_from_context=True,
),
],
pre_handler=_resolve_project_pre_handler,
),
"update": ToolMetadata(
name="comments_update",
description="Update comment details.",
sdk_method="update_work_item_comment",
parameters=[
ToolParameter(name="comment_id", description="Comment ID", required=True, type="str"),
ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"),
ToolParameter(name="comment_html", description="Comment content in HTML format", required=False, type="Optional[str]"),
ToolParameter(
name="project_id", description="Project ID (optional, auto-filled)", required=False, type="Optional[str]", auto_fill_from_context=True
),
ToolParameter(
name="workspace_slug",
description="Workspace slug (optional, auto-filled)",
required=False,
type="Optional[str]",
auto_fill_from_context=True,
),
ToolParameter(name="external_source", description="External source identifier", required=False, type="Optional[str]"),
ToolParameter(name="external_id", description="External system ID", required=False, type="Optional[str]"),
],
pre_handler=_resolve_project_pre_handler,
returns_entity_type="comment",
),
"delete": ToolMetadata(
name="comments_delete",
description="Delete a comment.",
sdk_method="delete_work_item_comment",
parameters=[
ToolParameter(name="comment_id", description="Comment ID", required=True, type="str"),
ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"),
ToolParameter(
name="project_id", description="Project ID (optional, auto-filled)", required=False, type="Optional[str]", auto_fill_from_context=True
),
ToolParameter(
name="workspace_slug",
description="Workspace slug (optional, auto-filled)",
required=False,
type="Optional[str]",
auto_fill_from_context=True,
),
],
pre_handler=_resolve_project_pre_handler,
returns_entity_type="comment",
),
}
def get_comment_tools(method_executor, context):
"""Return LangChain tools for the comments category using method_executor and context."""
async def _build_issue_entity(issue_id: str, comment_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Build entity payload for comment actions.
- entity_url points to the parent issue (workitem) URL
- entity_type is "comment"
- entity_id is the comment_id when available (update/delete always, create best-effort)
- entity_name is left null for now (TODO)
"""
try:
workspace_slug = context.get("workspace_slug") if isinstance(context, dict) else None
if not workspace_slug:
return None
from pi.agents.sql_agent.tools import construct_action_entity_url
url_info = await construct_action_entity_url({"id": str(issue_id)}, "workitem", workspace_slug, settings.plane_api.FRONTEND_URL)
if not url_info or not isinstance(url_info, dict):
return None
# Normalize expected keys for execute-action consumers
issue_identifier = url_info.get("issue_identifier")
entity: Dict[str, Any] = {
"entity_url": url_info.get("entity_url"),
"entity_name": None,
"entity_type": "comment",
"entity_id": str(comment_id) if comment_id else None,
}
if issue_identifier:
entity["issue_identifier"] = issue_identifier
entity["entity_identifier"] = issue_identifier
return entity
except Exception:
return None
@tool
async def comments_create(
issue_id: str,
comment_html: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
external_source: Optional[str] = None,
external_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a comment on an issue.
Args:
issue_id: Issue ID (required)
comment_html: Comment content in HTML format (required)
project_id: Project ID (optional, auto-filled from context or resolved from issue_id)
workspace_slug: Workspace slug (optional, auto-filled from context)
external_source: External source identifier (e.g., "jira")
external_id: External system ID
"""
print(f"Context in comments_create: {context}")
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Note: issue_id and project_id validation/resolution is handled centrally
# in action_execution_helpers.validate_and_resolve_ids() before tool execution
result = await method_executor.execute(
"comments",
"create",
issue_id=issue_id,
comment_html=comment_html,
external_source=external_source,
external_id=external_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
created_comment_id = None
try:
data = result.get("data") if isinstance(result, dict) else None
if isinstance(data, dict) and data.get("id"):
created_comment_id = str(data["id"])
except Exception:
created_comment_id = None
entity = await _build_issue_entity(issue_id, comment_id=created_comment_id)
return PlaneToolBase.format_success_payload("Successfully created comment", result["data"], entity=entity)
else:
return PlaneToolBase.format_error_payload("Failed to create comment", result["error"])
@tool
async def comments_list(issue_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""List comments for an issue."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute("comments", "list", issue_id=issue_id, project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved comments list", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list comments", result["error"])
@tool
async def comments_retrieve(
comment_id: str,
issue_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Get a single comment by ID.
Args:
comment_id: Comment ID (required)
issue_id: Issue ID (required)
project_id: Project ID (optional, auto-filled from context or resolved from issue_id)
workspace_slug: Workspace slug (optional, auto-filled from context)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Programmatically resolve project_id from issue_id if missing or invalid (non-UUID)
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
project_id = issue_info["project_id"]
except Exception:
pass
result = await method_executor.execute(
"comments",
"retrieve",
comment_id=comment_id,
issue_id=issue_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved comment", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to retrieve comment", result["error"])
@tool
async def comments_update(
comment_id: str,
issue_id: str,
comment_html: Optional[str] = None,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
external_source: Optional[str] = None,
external_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Update comment details.
Args:
comment_id: Comment ID (required)
issue_id: Issue ID (required)
comment_html: Comment content in HTML format
project_id: Project ID (optional, auto-filled from context or resolved from issue_id)
workspace_slug: Workspace slug (optional, auto-filled from context)
external_source: External source identifier (e.g., "jira")
external_id: External system ID
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Programmatically resolve project_id from issue_id if missing or invalid (non-UUID)
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
project_id = issue_info["project_id"]
except Exception:
pass
result = await method_executor.execute(
"comments",
"update",
comment_id=comment_id,
issue_id=issue_id,
comment_html=comment_html,
external_source=external_source,
external_id=external_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
entity = await _build_issue_entity(issue_id, comment_id=comment_id)
return PlaneToolBase.format_success_payload("Successfully updated comment", result["data"], entity=entity)
else:
return PlaneToolBase.format_error_payload("Failed to update comment", result["error"])
@tool
async def comments_delete(
comment_id: str,
issue_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Delete a comment.
Args:
comment_id: Comment ID (required)
issue_id: Issue ID (required)
project_id: Project ID (optional, auto-filled from context or resolved from issue_id)
workspace_slug: Workspace slug (optional, auto-filled from context)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Programmatically resolve project_id from issue_id if missing or invalid (non-UUID)
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
project_id = issue_info["project_id"]
except Exception:
pass
result = await method_executor.execute(
"comments",
"delete",
comment_id=comment_id,
issue_id=issue_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
entity = await _build_issue_entity(issue_id, comment_id=comment_id)
return PlaneToolBase.format_success_payload("Successfully deleted comment", result["data"], entity=entity)
else:
return PlaneToolBase.format_error_payload("Failed to delete comment", result["error"])
return [comments_create, comments_list, comments_retrieve, comments_update, comments_delete]
return generate_tools_for_category("comments", method_executor, context, COMMENTS_TOOL_DEFINITIONS)
@@ -0,0 +1,264 @@
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
#
# Licensed under the Plane Commercial License (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://plane.so/legals/eula
#
# DO NOT remove or modify this notice.
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
"""
Customers API tools for Plane customer relationship management.
"""
from typing import Dict
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
# ============================================================================
# CUSTOMERS TOOL DEFINITIONS
# ============================================================================
CUSTOMER_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"create": ToolMetadata(
name="customers_create",
description="Create a new customer",
sdk_method="create_customer",
returns_entity_type="customer",
parameters=[
ToolParameter(
name="name",
type="str",
required=True,
description="Customer name (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="description",
type="Optional[str]",
required=False,
description="Plain text description (optional)",
),
ToolParameter(
name="description_html",
type="Optional[str]",
required=False,
description="HTML description (optional)",
),
ToolParameter(
name="email",
type="Optional[str]",
required=False,
description="Customer contact email (optional)",
),
ToolParameter(
name="website_url",
type="Optional[str]",
required=False,
description="Customer website URL (optional)",
),
ToolParameter(
name="logo_props",
type="Optional[dict]",
required=False,
description="Logo configuration JSON (optional)",
),
ToolParameter(
name="domain",
type="Optional[str]",
required=False,
description="Customer domain (optional)",
),
ToolParameter(
name="employees",
type="Optional[int]",
required=False,
description="Number of employees (optional)",
),
ToolParameter(
name="stage",
type="Optional[str]",
required=False,
description="Customer stage/lifecycle (optional)",
),
ToolParameter(
name="contract_status",
type="Optional[str]",
required=False,
description="Contract status (optional)",
),
ToolParameter(
name="revenue",
type="Optional[float]",
required=False,
description="Annual revenue (optional)",
),
],
),
"list": ToolMetadata(
name="customers_list",
description="List all customers in the workspace",
sdk_method="list_customers",
parameters=[
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"retrieve": ToolMetadata(
name="customers_retrieve",
description="Retrieve a single customer by ID",
sdk_method="retrieve_customer",
parameters=[
ToolParameter(
name="customer_id",
type="str",
required=True,
description="Customer ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"update": ToolMetadata(
name="customers_update",
description="Update customer details",
sdk_method="update_customer",
returns_entity_type="customer",
parameters=[
ToolParameter(
name="customer_id",
type="str",
required=True,
description="Customer ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="name",
type="Optional[str]",
required=False,
description="New customer name",
),
ToolParameter(
name="description",
type="Optional[str]",
required=False,
description="New plain text description",
),
ToolParameter(
name="description_html",
type="Optional[str]",
required=False,
description="New HTML description",
),
ToolParameter(
name="email",
type="Optional[str]",
required=False,
description="New contact email",
),
ToolParameter(
name="website_url",
type="Optional[str]",
required=False,
description="New website URL",
),
ToolParameter(
name="logo_props",
type="Optional[dict]",
required=False,
description="New logo configuration JSON",
),
ToolParameter(
name="domain",
type="Optional[str]",
required=False,
description="New domain",
),
ToolParameter(
name="employees",
type="Optional[int]",
required=False,
description="New employee count",
),
ToolParameter(
name="stage",
type="Optional[str]",
required=False,
description="New customer stage",
),
ToolParameter(
name="contract_status",
type="Optional[str]",
required=False,
description="New contract status",
),
ToolParameter(
name="revenue",
type="Optional[float]",
required=False,
description="New annual revenue",
),
],
),
"delete": ToolMetadata(
name="customers_delete",
description="Delete a customer",
sdk_method="delete_customer",
parameters=[
ToolParameter(
name="customer_id",
type="str",
required=True,
description="Customer ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
}
# ============================================================================
# TOOL FACTORY
# ============================================================================
def get_customer_tools(method_executor, context):
"""Return LangChain tools for the customers category using auto-generation from metadata."""
return generate_tools_for_category(
category="customers",
method_executor=method_executor,
context=context,
tool_definitions=CUSTOMER_TOOL_DEFINITIONS,
)
+159 -358
View File
@@ -13,368 +13,169 @@
Cycles API tools for Plane project cycle management operations.
"""
from typing import Any
from typing import Dict
from typing import Optional
from langchain_core.tools import tool
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
from .base import PlaneToolBase
# ============================================================================
# CYCLES TOOL DEFINITIONS
# ============================================================================
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing cycle actions
CYCLES_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"create": ToolMetadata(
name="cycles_create",
description="Create a new cycle. Note: Both start_date and end_date must be provided together, or neither.",
sdk_method="create_cycle",
returns_entity_type="cycle",
parameters=[
ToolParameter(name="name", type="str", required=True, description="Cycle name (required)"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
ToolParameter(
name="start_date", type="Optional[str]", required=False, description="Start date (YYYY-MM-DD). Must be paired with end_date."
),
ToolParameter(
name="end_date", type="Optional[str]", required=False, description="End date (YYYY-MM-DD). Must be paired with start_date."
),
ToolParameter(name="description", type="Optional[str]", required=False, description="Cycle description"),
ToolParameter(name="owned_by", type="Optional[str]", required=False, description="User ID who owns the cycle"),
ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system identifier"),
ToolParameter(name="external_source", type="Optional[str]", required=False, description="External system source name"),
],
),
"list": ToolMetadata(
name="cycles_list",
description="List cycles in a project. Requires project_id.",
sdk_method="list_cycles",
parameters=[
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
ToolParameter(name="per_page", type="Optional[int]", required=False, description="Number of cycles per page (default: 20)"),
ToolParameter(name="cursor", type="Optional[str]", required=False, description="Pagination cursor for next page"),
ToolParameter(name="cycle_view", type="Optional[str]", required=False, description="Filter cycles by status"),
],
),
"retrieve": ToolMetadata(
name="cycles_retrieve",
description="Retrieve a single cycle by ID.",
sdk_method="retrieve_cycle",
parameters=[
ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"),
ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"update": ToolMetadata(
name="cycles_update",
description="Update cycle details.",
sdk_method="update_cycle",
returns_entity_type="cycle",
parameters=[
ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"),
ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
ToolParameter(name="name", type="Optional[str]", required=False, description="New cycle name"),
ToolParameter(name="description", type="Optional[str]", required=False, description="New description"),
ToolParameter(name="start_date", type="Optional[str]", required=False, description="New start date (YYYY-MM-DD)"),
ToolParameter(name="end_date", type="Optional[str]", required=False, description="New end date (YYYY-MM-DD)"),
ToolParameter(name="owned_by", type="Optional[str]", required=False, description="New owner user ID"),
ToolParameter(name="external_id", type="Optional[str]", required=False, description="External identifier"),
ToolParameter(name="external_source", type="Optional[str]", required=False, description="External source name"),
],
),
"archive": ToolMetadata(
name="cycles_archive",
description="Archive a cycle.",
sdk_method="archive_cycle",
parameters=[
ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"),
ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"unarchive": ToolMetadata(
name="cycles_unarchive",
description="Unarchive (restore) a cycle.",
sdk_method="unarchive_cycle",
parameters=[
ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"),
ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"list_archived": ToolMetadata(
name="cycles_list_archived",
description="List archived cycles in a project.",
sdk_method="list_archived_cycles",
parameters=[
ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"add_work_items": ToolMetadata(
name="cycles_add_work_items",
description="Add work items to a cycle.",
sdk_method="add_cycle_work_items",
parameters=[
ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"),
ToolParameter(name="issues", type="list", required=True, description="List of issue IDs to add to the cycle"),
ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"list_work_items": ToolMetadata(
name="cycles_list_work_items",
description="List work items in a cycle.",
sdk_method="list_cycle_work_items",
parameters=[
ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"),
ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"retrieve_work_item": ToolMetadata(
name="cycles_retrieve_work_item",
description="Get a specific work item in a cycle.",
sdk_method="retrieve_cycle_work_item",
parameters=[
ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"),
ToolParameter(name="issue_id", type="str", required=True, description="Issue ID (required)"),
ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"remove_work_item": ToolMetadata(
name="cycles_remove_work_item",
description="Remove a work item from a cycle.",
sdk_method="remove_cycle_work_item",
parameters=[
ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"),
ToolParameter(name="issue_id", type="str", required=True, description="Issue ID to remove (required)"),
ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"transfer_work_items": ToolMetadata(
name="cycles_transfer_work_items",
description="Transfer work items between cycles.",
sdk_method="transfer_cycle_work_items",
parameters=[
ToolParameter(name="cycle_id", type="str", required=True, description="Source cycle ID (required)"),
ToolParameter(name="new_cycle_id", type="str", required=True, description="Destination cycle ID (required)"),
ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
}
# ============================================================================
# TOOL FACTORY
# ============================================================================
def get_cycle_tools(method_executor, context):
"""Return LangChain tools for the cycles category using method_executor and context."""
@tool
async def cycles_create(
name: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
description: Optional[str] = None,
owned_by: Optional[str] = None,
external_id: Optional[str] = None,
external_source: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a new cycle.
Args:
name: Cycle name (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (required - provide from conversation context)
start_date: Start date in YYYY-MM-DD format (IMPORTANT: if provided, end_date must also be provided)
end_date: End date in YYYY-MM-DD format (IMPORTANT: if provided, start_date must also be provided)
description: Cycle description (optional)
owned_by: User ID who owns the cycle (provide when user specifies ownership or assignment)
external_id: External system identifier (optional)
external_source: External system source name (optional)
Note: Both start_date and end_date must be provided together, or neither should be provided.
You cannot provide only one date - the API will reject the request.
"""
# Auto-fill from context if not provided
if workspace_slug is None:
if "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None:
if "project_id" in context:
project_id = context["project_id"]
# project_id and workspace_slug are now required parameters
result = await method_executor.execute(
"cycles",
"create",
name=name,
project_id=project_id,
workspace_slug=workspace_slug,
start_date=start_date,
end_date=end_date,
description=description,
owned_by=owned_by,
external_id=external_id,
external_source=external_source,
)
if result["success"]:
return await PlaneToolBase.format_success_payload_with_url(f"Successfully created cycle '{name}'", result["data"], "cycle", context)
else:
return PlaneToolBase.format_error_payload("Failed to create cycle", result["error"])
@tool
async def cycles_list(
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
per_page: Optional[int] = 20,
cursor: Optional[str] = None,
cycle_view: Optional[str] = None,
) -> Dict[str, Any]:
"""List cycles in a project.
Args:
project_id: Project ID (REQUIRED - provide from conversation context or previous actions)
workspace_slug: Workspace slug (auto-filled from context; LLM-supplied value will be overridden)
per_page: Number of cycles per page (default: 20, max: 100)
cursor: Pagination cursor for next page
cycle_view: Filter cycles by status (optional)
Important: This tool REQUIRES a project_id. If you don't have one, you MUST first:
1. Call search_project_by_name to find the project, OR
2. Call projects_list to see available projects, OR
3. Call ask_for_clarification to ask the user which project to use
"""
# Auto-fill and harden workspace/project context
# Always prefer the execution context over LLM-provided values to avoid permission errors
if "workspace_slug" in context and context.get("workspace_slug"):
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Validate required parameters after auto-fill
if not project_id or not workspace_slug:
return PlaneToolBase.format_error_payload(
"Failed to list cycles",
"Missing required context: project_id/workspace_slug",
)
result = await method_executor.execute(
"cycles",
"list",
project_id=project_id,
workspace_slug=workspace_slug,
per_page=per_page,
cursor=cursor,
cycle_view=cycle_view,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved cycles list", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list cycles", result["error"])
@tool
async def cycles_retrieve(cycle_id: str, project_id: str, workspace_slug: str) -> Dict[str, Any]:
"""Retrieve a single cycle by ID.
Args:
cycle_id: Cycle ID (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
"""
# project_id and workspace_slug are now required parameters
result = await method_executor.execute("cycles", "retrieve", cycle_id=cycle_id, project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved cycle", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to retrieve cycle", result["error"])
@tool
async def cycles_update(
cycle_id: str,
project_id: str,
workspace_slug: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
owned_by: Optional[str] = None,
external_id: Optional[str] = None,
external_source: Optional[str] = None,
) -> Dict[str, Any]:
"""Update cycle details.
Args:
cycle_id: Cycle ID (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
name: New cycle name
description: New description
start_date: New start date (YYYY-MM-DD)
end_date: New end date (YYYY-MM-DD)
owned_by: New owner user ID (optional)
external_id: External system identifier (optional)
external_source: External system source name (optional)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
# Build update request object with non-None values only
update_data = {}
if name is not None:
update_data["name"] = name
if description is not None:
update_data["description"] = description
if start_date is not None:
update_data["start_date"] = start_date
if end_date is not None:
update_data["end_date"] = end_date
if owned_by is not None:
update_data["owned_by"] = owned_by
if external_id is not None:
update_data["external_id"] = external_id
if external_source is not None:
update_data["external_source"] = external_source
result = await method_executor.execute(
"cycles", "update", cycle_id=cycle_id, project_id=project_id, workspace_slug=workspace_slug, **update_data
)
if result["success"]:
return await PlaneToolBase.format_success_payload_with_url("Successfully updated cycle", result["data"], "cycle", context)
else:
return PlaneToolBase.format_error_payload("Failed to update cycle", result["error"])
@tool
async def cycles_archive(cycle_id: str, project_id: str, workspace_slug: str) -> Dict[str, Any]:
"""Archive a cycle.
Args:
cycle_id: Cycle ID (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
"""
# project_id and workspace_slug are now required parameters
result = await method_executor.execute("cycles", "archive", cycle_id=cycle_id, project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully archived cycle", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to archive cycle", result["error"])
@tool
async def cycles_add_work_items(cycle_id: str, issues: list, project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""Add work items to a cycle.
Args:
cycle_id: Cycle ID (required)
issues: List of issue IDs to add to the cycle (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
"""
# Auto-fill from context if not provided
if workspace_slug is None:
if "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
# Check if issues list is empty
if not issues or len(issues) == 0:
return PlaneToolBase.format_success_payload(
"No work items to add to cycle", {"message": "There are no work items satisfying the criteria to add to the cycle", "issues_count": 0}
)
# project_id and workspace_slug are now required parameters
result = await method_executor.execute(
"cycles", "add_work_items", cycle_id=cycle_id, issues=issues, project_id=project_id, workspace_slug=workspace_slug
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully added work items to cycle", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to add work items to cycle", result["error"])
@tool
async def cycles_remove_work_item(cycle_id: str, issue_id: str, project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""Remove a work item from a cycle.
Args:
cycle_id: Cycle ID (required)
issue_id: Issue ID to remove from the cycle (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
"""
# Auto-fill from context if not provided
if workspace_slug is None:
if "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
# project_id and workspace_slug are now required parameters
result = await method_executor.execute(
"cycles", "remove_work_item", cycle_id=cycle_id, issue_id=issue_id, project_id=project_id, workspace_slug=workspace_slug
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully removed work item from cycle", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to remove work item from cycle", result["error"])
@tool
async def cycles_unarchive(cycle_id: str, project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""Unarchive a cycle."""
# Auto-fill from context if not provided
if workspace_slug is None:
if "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
# project_id and workspace_slug are now required parameters
result = await method_executor.execute("cycles", "unarchive", cycle_id=cycle_id, project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload(f"Successfully unarchived cycle '{cycle_id}'", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to unarchive cycle", result["error"])
@tool
async def cycles_list_archived(project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""List archived cycles."""
# Auto-fill from context if not provided
if workspace_slug is None:
if "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
# project_id and workspace_slug are now required parameters
result = await method_executor.execute("cycles", "list_archived", project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved archived cycles list", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list archived cycles", result["error"])
@tool
async def cycles_list_work_items(cycle_id: str, project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""List work items in a cycle."""
# Auto-fill from context if not provided
if workspace_slug is None:
if "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
# project_id and workspace_slug are now required parameters
result = await method_executor.execute("cycles", "list_work_items", cycle_id=cycle_id, project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved cycle work items list", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list cycle work items", result["error"])
@tool
async def cycles_retrieve_work_item(cycle_id: str, issue_id: str, project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""Get a specific work item in a cycle."""
# Auto-fill from context if not provided
if workspace_slug is None:
if "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
# project_id and workspace_slug are now required parameters
result = await method_executor.execute(
"cycles", "retrieve_work_item", cycle_id=cycle_id, issue_id=issue_id, project_id=project_id, workspace_slug=workspace_slug
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved cycle work item", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to retrieve cycle work item", result["error"])
@tool
async def cycles_transfer_work_items(cycle_id: str, new_cycle_id: str, project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""Transfer work items between cycles."""
# Auto-fill from context if not provided
if workspace_slug is None:
if "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
# project_id and workspace_slug are now required parameters
result = await method_executor.execute(
"cycles", "transfer_work_items", cycle_id=cycle_id, new_cycle_id=new_cycle_id, project_id=project_id, workspace_slug=workspace_slug
)
if result["success"]:
return PlaneToolBase.format_success_payload(
f"Successfully transferred work items from cycle '{cycle_id}' to '{new_cycle_id}'", result["data"]
)
else:
return PlaneToolBase.format_error_payload("Failed to transfer cycle work items", result["error"])
return [
cycles_create,
cycles_update,
cycles_archive,
cycles_unarchive,
cycles_add_work_items,
cycles_remove_work_item,
cycles_transfer_work_items,
cycles_retrieve,
cycles_list,
cycles_list_archived,
cycles_list_work_items,
cycles_retrieve_work_item,
]
"""Return LangChain tools for the cycles category using auto-generation from metadata."""
return generate_tools_for_category("cycles", method_executor, context, CYCLES_TOOL_DEFINITIONS)
@@ -0,0 +1,535 @@
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
#
# Licensed under the Plane Commercial License (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://plane.so/legals/eula
#
# DO NOT remove or modify this notice.
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
"""
Initiatives API tools for Plane strategic planning operations.
"""
from typing import Dict
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
# ============================================================================
# INITIATIVES TOOL DEFINITIONS
# ============================================================================
INITIATIVE_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"create": ToolMetadata(
name="initiatives_create",
description="Create a new strategic initiative",
sdk_method="create_initiative",
returns_entity_type="initiative",
parameters=[
ToolParameter(
name="name",
type="str",
required=True,
description="Initiative name (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="description_html",
type="Optional[str]",
required=False,
description="HTML description of the initiative",
),
ToolParameter(
name="start_date",
type="Optional[str]",
required=False,
description="Initiative start date (YYYY-MM-DD)",
),
ToolParameter(
name="end_date",
type="Optional[str]",
required=False,
description="Initiative end date (YYYY-MM-DD)",
),
ToolParameter(
name="state",
type="Optional[str]",
required=False,
description="Initiative state. Must be one of: DRAFT, PLANNED, ACTIVE, COMPLETED, CLOSED (uppercase required). Defaults to DRAFT if not provided.", # noqa: E501
),
ToolParameter(
name="lead",
type="Optional[str]",
required=False,
description="Initiative lead user ID",
),
],
),
"list": ToolMetadata(
name="initiatives_list",
description="List initiatives in the workspace",
sdk_method="list_initiatives",
parameters=[
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"retrieve": ToolMetadata(
name="initiatives_retrieve",
description="Retrieve a single initiative by ID",
sdk_method="retrieve_initiative",
parameters=[
ToolParameter(
name="initiative_id",
type="str",
required=True,
description="Initiative ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"update": ToolMetadata(
name="initiatives_update",
description="Update initiative details",
sdk_method="update_initiative",
returns_entity_type="initiative",
parameters=[
ToolParameter(
name="initiative_id",
type="str",
required=True,
description="Initiative ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="name",
type="Optional[str]",
required=False,
description="New initiative name",
),
ToolParameter(
name="description_html",
type="Optional[str]",
required=False,
description="New HTML description",
),
ToolParameter(
name="start_date",
type="Optional[str]",
required=False,
description="New start date (YYYY-MM-DD)",
),
ToolParameter(
name="end_date",
type="Optional[str]",
required=False,
description="New end date (YYYY-MM-DD)",
),
ToolParameter(
name="logo_props",
type="Optional[dict]",
required=False,
description="New logo configuration JSON",
),
ToolParameter(
name="state",
type="Optional[str]",
required=False,
description="New initiative state. Must be one of: DRAFT, PLANNED, ACTIVE, COMPLETED, CLOSED (uppercase required)",
),
ToolParameter(
name="lead",
type="Optional[str]",
required=False,
description="New initiative lead user ID",
),
],
),
"delete": ToolMetadata(
name="initiatives_delete",
description="Delete an initiative",
sdk_method="delete_initiative",
parameters=[
ToolParameter(
name="initiative_id",
type="str",
required=True,
description="Initiative ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
# Label operations
"create_label": ToolMetadata(
name="initiatives_create_label",
description="Create a workspace-level initiative label (can later be attached to initiatives)",
sdk_method="create_initiative_label",
returns_entity_type="initiative_label",
parameters=[
ToolParameter(
name="name",
type="str",
required=True,
description="Label name (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="color",
type="Optional[str]",
required=False,
description="Label color hex code",
),
ToolParameter(
name="description",
type="Optional[str]",
required=False,
description="Label description",
),
],
),
"list_labels": ToolMetadata(
name="initiatives_list_labels",
description="List all workspace-level initiative labels",
sdk_method="list_initiative_labels",
parameters=[
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"retrieve_label": ToolMetadata(
name="initiatives_retrieve_label",
description="Get a single initiative label by ID",
sdk_method="retrieve_initiative_label",
parameters=[
ToolParameter(
name="label_id",
type="str",
required=True,
description="Label ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"update_label": ToolMetadata(
name="initiatives_update_label",
description="Update an initiative label",
sdk_method="update_initiative_label",
returns_entity_type="initiative_label",
parameters=[
ToolParameter(
name="label_id",
type="str",
required=True,
description="Label ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="name",
type="Optional[str]",
required=False,
description="New label name",
),
ToolParameter(
name="color",
type="Optional[str]",
required=False,
description="New label color hex code",
),
ToolParameter(
name="description",
type="Optional[str]",
required=False,
description="New label description",
),
],
),
"delete_label": ToolMetadata(
name="initiatives_delete_label",
description="Delete an initiative label",
sdk_method="delete_initiative_label",
parameters=[
ToolParameter(
name="label_id",
type="str",
required=True,
description="Label ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"add_labels": ToolMetadata(
name="initiatives_add_labels",
description="Attach existing labels to an initiative",
sdk_method="add_initiative_labels",
parameters=[
ToolParameter(
name="initiative_id",
type="str",
required=True,
description="Initiative ID (required)",
),
ToolParameter(
name="label_ids",
type="List[str]",
required=True,
description="List of label IDs to attach (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"remove_labels": ToolMetadata(
name="initiatives_remove_labels",
description="Detach labels from an initiative",
sdk_method="remove_initiative_labels",
parameters=[
ToolParameter(
name="initiative_id",
type="str",
required=True,
description="Initiative ID (required)",
),
ToolParameter(
name="label_ids",
type="List[str]",
required=True,
description="List of label IDs to detach (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
# Project operations
"add_projects": ToolMetadata(
name="initiatives_add_projects",
description="Link projects to an initiative",
sdk_method="add_initiative_projects",
parameters=[
ToolParameter(
name="initiative_id",
type="str",
required=True,
description="Initiative ID (required)",
),
ToolParameter(
name="project_ids",
type="List[str]",
required=True,
description="List of project IDs to link (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"list_projects": ToolMetadata(
name="initiatives_list_projects",
description="List projects linked to an initiative",
sdk_method="list_initiative_projects",
parameters=[
ToolParameter(
name="initiative_id",
type="str",
required=True,
description="Initiative ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"remove_projects": ToolMetadata(
name="initiatives_remove_projects",
description="Unlink projects from an initiative",
sdk_method="remove_initiative_projects",
parameters=[
ToolParameter(
name="initiative_id",
type="str",
required=True,
description="Initiative ID (required)",
),
ToolParameter(
name="project_ids",
type="List[str]",
required=True,
description="List of project IDs to unlink (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
# Epic operations
"add_epics": ToolMetadata(
name="initiatives_add_epics",
description="Link epics to an initiative",
sdk_method="add_initiative_epics",
parameters=[
ToolParameter(
name="initiative_id",
type="str",
required=True,
description="Initiative ID (required)",
),
ToolParameter(
name="epic_ids",
type="List[str]",
required=True,
description="List of epic IDs to link (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"list_epics": ToolMetadata(
name="initiatives_list_epics",
description="List epics linked to an initiative",
sdk_method="list_initiative_epics",
parameters=[
ToolParameter(
name="initiative_id",
type="str",
required=True,
description="Initiative ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"remove_epics": ToolMetadata(
name="initiatives_remove_epics",
description="Unlink epics from an initiative",
sdk_method="remove_initiative_epics",
parameters=[
ToolParameter(
name="initiative_id",
type="str",
required=True,
description="Initiative ID (required)",
),
ToolParameter(
name="epic_ids",
type="List[str]",
required=True,
description="List of epic IDs to unlink (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
}
# ============================================================================
# TOOL FACTORY
# ============================================================================
def get_initiative_tools(method_executor, context):
"""Return LangChain tools for the initiatives category using auto-generation from metadata."""
return generate_tools_for_category(
category="initiatives",
method_executor=method_executor,
context=context,
tool_definitions=INITIATIVE_TOOL_DEFINITIONS,
)
+79 -186
View File
@@ -13,195 +13,88 @@
Intake API tools for Plane work item triage operations.
"""
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
from langchain_core.tools import tool
# ============================================================================
# TOOL METADATA DEFINITIONS
# ============================================================================
from .base import PlaneToolBase
INTAKE_TOOL_DEFINITIONS = {
"create": ToolMetadata(
name="intake_create",
description="Submit work item to intake queue for triage. Creates a new intake item that can be reviewed and converted to a work item.",
sdk_method="create_intake",
parameters=[
ToolParameter(name="name", type="str", required=True, description="Work item title (required)"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
ToolParameter(name="description_html", type="Optional[str]", required=False, description="Description in HTML format"),
ToolParameter(name="priority", type="Optional[str]", required=False, description="Priority level (high, medium, low, urgent, none)"),
ToolParameter(name="assignee", type="Optional[str]", required=False, description="Assignee user ID"),
ToolParameter(name="reporter", type="Optional[str]", required=False, description="Reporter user ID"),
ToolParameter(name="labels", type="Optional[list]", required=False, description="List of label IDs"),
],
returns_entity_type="intake",
),
"list": ToolMetadata(
name="intake_list",
description="List intake items awaiting triage for a project.",
sdk_method="list_intake",
parameters=[
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
ToolParameter(name="per_page", type="Optional[int]", required=False, description="Number of results per page (default: 20)"),
ToolParameter(name="cursor", type="Optional[str]", required=False, description="Pagination cursor"),
],
),
"retrieve": ToolMetadata(
name="intake_retrieve",
description="Get a single intake work item by ID.",
sdk_method="retrieve_intake",
parameters=[
ToolParameter(name="intake_id", type="str", required=True, description="Intake item ID to retrieve"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"update": ToolMetadata(
name="intake_update",
description="Update intake work item details before triage.",
sdk_method="update_intake",
parameters=[
ToolParameter(name="intake_id", type="str", required=True, description="Intake item ID to update"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
ToolParameter(name="name", type="Optional[str]", required=False, description="Work item title"),
ToolParameter(name="description_html", type="Optional[str]", required=False, description="Description in HTML format"),
ToolParameter(name="priority", type="Optional[str]", required=False, description="Priority level (high, medium, low, urgent, none)"),
ToolParameter(name="assignee", type="Optional[str]", required=False, description="Assignee user ID"),
ToolParameter(name="reporter", type="Optional[str]", required=False, description="Reporter user ID"),
ToolParameter(name="labels", type="Optional[list]", required=False, description="List of label IDs"),
],
returns_entity_type="intake",
),
"delete": ToolMetadata(
name="intake_delete",
description="Remove an intake work item from the triage queue.",
sdk_method="delete_intake",
parameters=[
ToolParameter(name="intake_id", type="str", required=True, description="Intake item ID to delete"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
returns_entity_type="intake",
),
}
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing intake actions
# ============================================================================
# TOOL FACTORY
# ============================================================================
def get_intake_tools(method_executor, context):
"""Return LangChain tools for the intake category using method_executor and context."""
@tool
async def intake_create(
name: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
description_html: Optional[str] = None,
priority: Optional[str] = None,
assignee: Optional[str] = None,
reporter: Optional[str] = None,
labels: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Submit work item to intake queue for triage.
Args:
name: Work item title (required)
description_html: Description in HTML format
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
priority: Priority level (high, medium, low, urgent, none)
assignee: Assignee user ID
reporter: Reporter user ID
labels: List of label IDs
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"intake",
"create",
name=name,
description_html=description_html,
project_id=project_id,
workspace_slug=workspace_slug,
priority=priority,
assignee=assignee,
reporter=reporter,
labels=labels,
)
if result["success"]:
return PlaneToolBase.format_success_payload(f"Successfully submitted intake item '{name}'", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to create intake item", result["error"])
@tool
async def intake_list(
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
per_page: Optional[int] = 20,
cursor: Optional[str] = None,
) -> Dict[str, Any]:
"""List intake items awaiting triage."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"intake",
"list",
project_id=project_id,
workspace_slug=workspace_slug,
per_page=per_page,
cursor=cursor,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved intake list", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list intake items", result["error"])
@tool
async def intake_retrieve(
intake_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Get a single intake work item by ID."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"intake",
"retrieve",
intake_id=intake_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved intake item", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to retrieve intake item", result["error"])
@tool
async def intake_update(
intake_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
name: Optional[str] = None,
description_html: Optional[str] = None,
priority: Optional[str] = None,
assignee: Optional[str] = None,
reporter: Optional[str] = None,
labels: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Update intake work item details."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Build update data
update_data: Dict[str, Any] = {}
if name is not None:
update_data["name"] = name
if description_html is not None:
update_data["description_html"] = description_html
if priority is not None:
update_data["priority"] = priority
if assignee is not None:
update_data["assignee"] = assignee
if reporter is not None:
update_data["reporter"] = reporter
if labels is not None:
update_data["labels"] = labels
result = await method_executor.execute(
"intake",
"update",
intake_id=intake_id,
project_id=project_id,
workspace_slug=workspace_slug,
**update_data,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully updated intake item", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to update intake item", result["error"])
@tool
async def intake_delete(
intake_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Remove intake work item."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"intake",
"delete",
intake_id=intake_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully deleted intake item", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to delete intake item", result["error"])
return [intake_create, intake_list, intake_retrieve, intake_update, intake_delete]
"""Return LangChain tools for the intake category using auto-generation from metadata."""
return generate_tools_for_category("intake", method_executor, context, INTAKE_TOOL_DEFINITIONS)
+331 -171
View File
@@ -11,189 +11,349 @@
"""
Labels API tools for Plane labeling operations.
MIGRATED TO AUTO-GENERATED TOOLS
Tool metadata defined in this file (LABEL_TOOL_DEFINITIONS) for modularity.
Old manual definitions kept below for comparison/rollback safety.
"""
from typing import Any
from typing import Dict
from typing import Optional
from langchain_core.tools import tool
from pi.services.actions.tool_generator import generate_tools_for_category
from .base import PlaneToolBase
# Import tool metadata models
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
# Tool metadata for labels category - single source of truth
LABEL_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"create": ToolMetadata(
name="labels_create",
description="Create a new label",
sdk_method="create_label",
returns_entity_type="label",
parameters=[
ToolParameter(name="name", type="str", required=True, description="Label name (required)"),
ToolParameter(name="color", type="str", required=True, description="Label color in hex format (required)"),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (required - provide from conversation context or previous actions)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (provide if known, otherwise auto-detected)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(name="description", type="Optional[str]", required=False, description="Label description", property_transform="name"),
ToolParameter(
name="parent", type="Optional[str]", required=False, description="Parent label ID for nested labels", property_transform="id"
),
ToolParameter(
name="sort_order", type="Optional[float]", required=False, description="Sort order for display (float)", property_transform="name"
),
ToolParameter(
name="external_source",
type="Optional[str]",
required=False,
description='External source identifier (e.g., "jira")',
property_transform="name",
),
ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system ID", property_transform="name"),
],
),
"list": ToolMetadata(
name="labels_list",
description="List all labels in a project",
sdk_method="list_labels",
parameters=[
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (required - provide from conversation context or previous actions)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (provide if known, otherwise auto-detected)",
auto_fill_from_context=True,
property_transform="skip",
),
],
),
"retrieve": ToolMetadata(
name="labels_retrieve",
description="Retrieve details of a specific label",
sdk_method="retrieve_label",
parameters=[
ToolParameter(name="label_id", type="str", required=True, description="Label ID (required)"),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (required - provide from conversation context or previous actions)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (provide if known, otherwise auto-detected)",
auto_fill_from_context=True,
property_transform="skip",
),
],
),
"update": ToolMetadata(
name="labels_update",
description="Update an existing label",
sdk_method="update_label",
returns_entity_type="label",
parameters=[
ToolParameter(name="label_id", type="str", required=True, description="Label ID (required)"),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (required - provide from conversation context or previous actions)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (provide if known, otherwise auto-detected)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(name="name", type="Optional[str]", required=False, description="New label name", property_transform="name"),
ToolParameter(name="color", type="Optional[str]", required=False, description="New label color", property_transform="name"),
ToolParameter(name="description", type="Optional[str]", required=False, description="New label description", property_transform="name"),
ToolParameter(
name="parent", type="Optional[str]", required=False, description="Parent label ID for nested labels", property_transform="id"
),
ToolParameter(
name="sort_order", type="Optional[float]", required=False, description="Sort order for display (float)", property_transform="name"
),
ToolParameter(
name="external_source",
type="Optional[str]",
required=False,
description='External source identifier (e.g., "jira")',
property_transform="name",
),
ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system ID", property_transform="name"),
],
),
}
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing label actions
def get_label_tools(method_executor, context):
"""Return LangChain tools for the labels category using method_executor and context."""
"""Return LangChain tools for the labels category using auto-generation from metadata."""
@tool
async def labels_create(
name: str,
color: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
description: Optional[str] = None,
parent: Optional[str] = None,
sort_order: Optional[float] = None,
external_source: Optional[str] = None,
external_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a new label.
# Generate all label tools from local metadata
label_tools = generate_tools_for_category(
category="labels",
method_executor=method_executor,
context=context,
tool_definitions=LABEL_TOOL_DEFINITIONS,
)
Args:
name: Label name (required)
color: Label color in hex format (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
description: Label description
parent: Parent label ID for nested labels
sort_order: Sort order for display (float)
external_source: External source identifier (e.g., "jira")
external_id: External system ID
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
return label_tools
result = await method_executor.execute(
"labels",
"create",
name=name,
color=color,
project_id=project_id,
workspace_slug=workspace_slug,
description=description,
parent=parent,
sort_order=sort_order,
external_source=external_source,
external_id=external_id,
)
if result["success"]:
return await PlaneToolBase.format_success_payload_with_url(f"Successfully created label '{name}'", result["data"], "label", context)
else:
return PlaneToolBase.format_error_payload("Failed to create label", result["error"])
# ============================================================================
# OLD MANUAL TOOL DEFINITIONS (COMMENTED OUT - KEPT FOR COMPARISON)
# To rollback: uncomment below and comment out the auto-generation code above
# ============================================================================
@tool
async def labels_list(
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""List all labels in a project."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"labels",
"list",
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved labels list", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list labels", result["error"])
@tool
async def labels_retrieve(
label_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Retrieve details of a specific label.
Args:
label_id: Label ID (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"labels",
"retrieve",
label_id=label_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved label details", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to retrieve label", result["error"])
@tool
async def labels_update(
label_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
name: Optional[str] = None,
color: Optional[str] = None,
description: Optional[str] = None,
parent: Optional[str] = None,
sort_order: Optional[float] = None,
external_source: Optional[str] = None,
external_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Update an existing label.
Args:
label_id: Label ID (required)
name: New label name
color: New label color
description: New label description
parent: Parent label ID for nested labels
sort_order: Sort order for display (float)
external_source: External source identifier (e.g., "jira")
external_id: External system ID
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Build update data with only non-None values
update_data = {
k: v
for k, v in {
"name": name,
"color": color,
"description": description,
"parent": parent,
"sort_order": sort_order,
"external_source": external_source,
"external_id": external_id,
}.items()
if v is not None
}
result = await method_executor.execute(
"labels",
"update",
label_id=label_id,
project_id=project_id,
workspace_slug=workspace_slug,
**update_data,
)
if result["success"]:
return await PlaneToolBase.format_success_payload_with_url(f"Successfully updated label '{label_id}'", result["data"], "label", context)
else:
return PlaneToolBase.format_error_payload("Failed to update label", result["error"])
return [labels_create, labels_list, labels_retrieve, labels_update]
# from typing import Optional
# from langchain_core.tools import tool
# from .base import PlaneToolBase
#
# def get_label_tools(method_executor, context):
# """Return LangChain tools for the labels category using method_executor and context."""
#
# @tool
# async def labels_create(
# name: str,
# color: str,
# project_id: Optional[str] = None,
# workspace_slug: Optional[str] = None,
# description: Optional[str] = None,
# parent: Optional[str] = None,
# sort_order: Optional[float] = None,
# external_source: Optional[str] = None,
# external_id: Optional[str] = None,
# ) -> Dict[str, Any]:
# """Create a new label.
#
# Args:
# name: Label name (required)
# color: Label color in hex format (required)
# project_id: Project ID (required - provide from conversation context or previous actions)
# workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
# description: Label description
# parent: Parent label ID for nested labels
# sort_order: Sort order for display (float)
# external_source: External source identifier (e.g., "jira")
# external_id: External system ID
# """
# # Auto-fill from context if not provided
# if workspace_slug is None and "workspace_slug" in context:
# workspace_slug = context["workspace_slug"]
# if project_id is None and "project_id" in context:
# project_id = context["project_id"]
#
# result = await method_executor.execute(
# "labels",
# "create",
# name=name,
# color=color,
# project_id=project_id,
# workspace_slug=workspace_slug,
# description=description,
# parent=parent,
# sort_order=sort_order,
# external_source=external_source,
# external_id=external_id,
# )
#
# if result["success"]:
# return await PlaneToolBase.format_success_payload_with_url(f"Successfully created label '{name}'", result["data"], "label", context)
# else:
# return PlaneToolBase.format_error_payload("Failed to create label", result["error"])
#
# @tool
# async def labels_list(
# project_id: Optional[str] = None,
# workspace_slug: Optional[str] = None,
# ) -> Dict[str, Any]:
# """List all labels in a project."""
# # Auto-fill from context if not provided
# if workspace_slug is None and "workspace_slug" in context:
# workspace_slug = context["workspace_slug"]
# if project_id is None and "project_id" in context:
# project_id = context["project_id"]
#
# result = await method_executor.execute(
# "labels",
# "list",
# project_id=project_id,
# workspace_slug=workspace_slug,
# )
#
# if result["success"]:
# return PlaneToolBase.format_success_payload("Successfully retrieved labels list", result["data"])
# else:
# return PlaneToolBase.format_error_payload("Failed to list labels", result["error"])
#
# @tool
# async def labels_retrieve(
# label_id: str,
# project_id: Optional[str] = None,
# workspace_slug: Optional[str] = None,
# ) -> Dict[str, Any]:
# """Retrieve details of a specific label.
#
# Args:
# label_id: Label ID (required)
# project_id: Project ID (required - provide from conversation context or previous actions)
# workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
# """
# # Auto-fill from context if not provided
# if workspace_slug is None and "workspace_slug" in context:
# workspace_slug = context["workspace_slug"]
# if project_id is None and "project_id" in context:
# project_id = context["project_id"]
#
# result = await method_executor.execute(
# "labels",
# "retrieve",
# label_id=label_id,
# project_id=project_id,
# workspace_slug=workspace_slug,
# )
#
# if result["success"]:
# return PlaneToolBase.format_success_payload("Successfully retrieved label details", result["data"])
# else:
# return PlaneToolBase.format_error_payload("Failed to retrieve label", result["error"])
#
# @tool
# async def labels_update(
# label_id: str,
# project_id: Optional[str] = None,
# workspace_slug: Optional[str] = None,
# name: Optional[str] = None,
# color: Optional[str] = None,
# description: Optional[str] = None,
# parent: Optional[str] = None,
# sort_order: Optional[float] = None,
# external_source: Optional[str] = None,
# external_id: Optional[str] = None,
# ) -> Dict[str, Any]:
# """Update an existing label.
#
# Args:
# label_id: Label ID (required)
# name: New label name
# color: New label color
# description: New label description
# parent: Parent label ID for nested labels
# sort_order: Sort order for display (float)
# external_source: External source identifier (e.g., "jira")
# external_id: External system ID
# project_id: Project ID (required - provide from conversation context or previous actions)
# workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
# """
# # Auto-fill from context if not provided
# if workspace_slug is None and "workspace_slug" in context:
# workspace_slug = context["workspace_slug"]
# if project_id is None and "project_id" in context:
# project_id = context["project_id"]
#
# # Build update data with only non-None values
# update_data = {
# k: v
# for k, v in {
# "name": name,
# "color": color,
# "description": description,
# "parent": parent,
# "sort_order": sort_order,
# "external_source": external_source,
# "external_id": external_id,
# }.items()
# if v is not None
# }
#
# result = await method_executor.execute(
# "labels",
# "update",
# label_id=label_id,
# project_id=project_id,
# workspace_slug=workspace_slug,
# **update_data,
# )
#
# if result["success"]:
# return await PlaneToolBase.format_success_payload_with_url(f"Successfully updated label '{label_id}'", result["data"], "label", context)
# else:
# return PlaneToolBase.format_error_payload("Failed to update label", result["error"])
#
# return [labels_create, labels_list, labels_retrieve, labels_update]
+110 -173
View File
@@ -13,185 +13,122 @@
Links API tools for Plane issue link management.
"""
import uuid
from typing import Any
from typing import Dict
from typing import Optional
from langchain_core.tools import tool
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
from .base import PlaneToolBase
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing link actions
# Pre-handler to resolve project_id from issue_id if missing
async def _resolve_project_pre_handler(
metadata: ToolMetadata,
kwargs: Dict[str, Any],
context: Dict[str, Any],
category: str,
method_key: str,
method_executor: Any,
) -> Dict[str, Any]:
"""Resolve project_id from issue_id if it is missing or invalid."""
issue_id = kwargs.get("issue_id")
project_id = kwargs.get("project_id")
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
kwargs["project_id"] = issue_info["project_id"]
except Exception:
# Best effort, proceed without resolution
pass
return kwargs
LINKS_TOOL_DEFINITIONS = {
"create": ToolMetadata(
name="links_create",
description="Create a link for an issue.",
sdk_method="create_work_item_link",
parameters=[
ToolParameter(name="issue_id", description="UUID of the work item", required=True, type="str"),
ToolParameter(name="url", description="The URL of the link", required=True, type="str"),
ToolParameter(name="title", description="Title/label for the link", required=False, type="Optional[str]"),
ToolParameter(name="project_id", description="UUID of the project", required=False, type="Optional[str]", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True),
ToolParameter(name="metadata", description="Link metadata", required=False, type="Optional[dict]"),
],
pre_handler=_resolve_project_pre_handler,
returns_entity_type="link",
),
"list": ToolMetadata(
name="links_list",
description="List links for an issue.",
sdk_method="list_work_item_links",
parameters=[
ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"),
ToolParameter(name="project_id", description="Project ID", required=False, type="Optional[str]", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True),
],
pre_handler=_resolve_project_pre_handler,
),
"retrieve": ToolMetadata(
name="links_retrieve",
description="Get a single link by ID.",
sdk_method="retrieve_work_item_link",
parameters=[
ToolParameter(name="link_id", description="UUID of the link", required=True, type="str"),
ToolParameter(name="issue_id", description="UUID of the work item", required=True, type="str"),
ToolParameter(name="project_id", description="UUID of the project", required=False, type="Optional[str]", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True),
],
pre_handler=_resolve_project_pre_handler,
),
"update": ToolMetadata(
name="links_update",
description="Update link details.",
sdk_method="update_issue_link",
parameters=[
ToolParameter(name="link_id", description="UUID of the link", required=True, type="str"),
ToolParameter(name="issue_id", description="UUID of the work item", required=True, type="str"),
ToolParameter(name="url", description="New URL", required=False, type="Optional[str]"),
ToolParameter(name="title", description="New title", required=False, type="Optional[str]"),
ToolParameter(name="project_id", description="UUID of the project", required=False, type="Optional[str]", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True),
ToolParameter(name="metadata", description="Link metadata", required=False, type="Optional[dict]"),
],
pre_handler=_resolve_project_pre_handler,
returns_entity_type="link",
),
"delete": ToolMetadata(
name="links_delete",
description="Delete a link.",
sdk_method="delete_work_item_link",
parameters=[
ToolParameter(name="link_id", description="UUID of the link", required=True, type="str"),
ToolParameter(name="issue_id", description="UUID of the work item", required=True, type="str"),
ToolParameter(name="project_id", description="UUID of the project", required=False, type="Optional[str]", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True),
],
pre_handler=_resolve_project_pre_handler,
returns_entity_type="link",
),
}
def get_link_tools(method_executor, context):
"""Return LangChain tools for the links category using method_executor and context."""
@tool
async def links_create(
issue_id: str,
url: str,
title: Optional[str] = None,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a link for an issue.
Args:
issue_id: UUID of the work item to create a link for (required)
url: The URL of the link (required)
title: Optional title/label for the link
project_id: UUID of the project (optional, auto-filled from context)
workspace_slug: Workspace slug identifier (optional, auto-filled from context)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"links",
"create",
issue_id=issue_id,
url=url,
title=title,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully created link", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to create link", result["error"])
@tool
async def links_list(issue_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""List links for an issue."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute("links", "list", issue_id=issue_id, project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved links list", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list links", result["error"])
@tool
async def links_retrieve(
link_id: str,
issue_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Get a single link by ID.
Args:
link_id: UUID of the link to retrieve (required)
issue_id: UUID of the work item the link belongs to (required)
project_id: UUID of the project (optional, auto-filled from context)
workspace_slug: Workspace slug identifier (optional, auto-filled from context)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"links",
"retrieve",
link_id=link_id,
issue_id=issue_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved link", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to retrieve link", result["error"])
@tool
async def links_update(
link_id: str,
issue_id: str,
url: Optional[str] = None,
title: Optional[str] = None,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Update link details.
Args:
link_id: UUID of the link to update (required)
issue_id: UUID of the work item the link belongs to (required)
url: New URL for the link (optional)
title: New title/label for the link (optional)
project_id: UUID of the project (optional, auto-filled from context)
workspace_slug: Workspace slug identifier (optional, auto-filled from context)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Build update data
update_data = {}
if url is not None:
update_data["url"] = url
if title is not None:
update_data["title"] = title
result = await method_executor.execute(
"links",
"update",
link_id=link_id,
issue_id=issue_id,
project_id=project_id,
workspace_slug=workspace_slug,
**update_data,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully updated link", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to update link", result["error"])
@tool
async def links_delete(
link_id: str,
issue_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Delete a link.
Args:
link_id: UUID of the link to delete (required)
issue_id: UUID of the work item the link belongs to (required)
project_id: UUID of the project (optional, auto-filled from context)
workspace_slug: Workspace slug identifier (optional, auto-filled from context)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"links",
"delete",
link_id=link_id,
issue_id=issue_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully deleted link", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to delete link", result["error"])
return [links_create, links_list, links_retrieve, links_update, links_delete]
return generate_tools_for_category("links", method_executor, context, LINKS_TOOL_DEFINITIONS)
+97 -84
View File
@@ -11,102 +11,115 @@
"""
Members API tools for Plane workspace and project member management.
MIGRATED TO AUTO-GENERATED TOOLS
Tool metadata defined in this file (MEMBER_TOOL_DEFINITIONS) for modularity.
Bot filtering handled via custom post_handler function.
Old manual definitions kept below for comparison/rollback safety.
"""
from typing import Any
from typing import Dict
from typing import Optional
from langchain_core.tools import tool
from pi import logger
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
from .base import PlaneToolBase
log = logger.getChild(__name__)
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing member actions
# ============================================================================
# MEMBERS-SPECIFIC HANDLER FUNCTIONS
# ============================================================================
async def _filter_bots_handler(
metadata: ToolMetadata,
result: Dict[str, Any],
kwargs: Dict[str, Any],
context: Dict[str, Any],
method_executor: Any,
category: str,
method_key: str,
) -> Dict[str, Any]:
"""Post-processing handler to filter bot users from member lists."""
if result["success"]:
members_data = result.get("data")
if isinstance(members_data, list):
filtered_members = [
member
for member in members_data
if not (member.get("is_bot", False) or (isinstance(member.get("email"), str) and "[email protected]" in member.get("email", "").lower()))
]
result["data"] = filtered_members
log.debug(f"Filtered {len(members_data) - len(filtered_members)} bot users from {metadata.name}")
return result
# Tool metadata for members category
MEMBER_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"get_workspace_members": ToolMetadata(
name="members_get_workspace_members",
description="Get all workspace members (excludes bot users)",
sdk_method="get_workspace_members",
post_handler=_filter_bots_handler,
parameters=[
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-filled from context)",
auto_fill_from_context=True,
property_transform="skip",
),
],
),
"get_project_members": ToolMetadata(
name="members_get_project_members",
description="Get all project members (excludes bot users)",
sdk_method="get_project_members",
post_handler=_filter_bots_handler,
parameters=[
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project UUID (auto-filled from context if in project chat)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-filled from context)",
auto_fill_from_context=True,
property_transform="skip",
),
],
),
}
def get_member_tools(method_executor, context):
"""Return LangChain tools for the members category using method_executor and context."""
"""Get all Members API tools."""
"""Return LangChain tools for the members category using auto-generation from metadata."""
@tool
async def members_get_workspace_members(workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""Get all workspace members (excludes bot users).
member_tools = generate_tools_for_category(
category="members",
method_executor=method_executor,
context=context,
tool_definitions=MEMBER_TOOL_DEFINITIONS,
)
Args:
workspace_slug: Workspace slug (auto-filled from context)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
return member_tools
result = await method_executor.execute("members", "get_workspace_members", workspace_slug=workspace_slug)
if result["success"]:
# Filter out bot users from the results
members_data = result["data"]
if isinstance(members_data, list):
# Filter out users with is_bot=True and bot email patterns
filtered_members = [
member
for member in members_data
if not (
# Check is_bot field if present
member.get("is_bot", False)
# Check for bot email pattern ([email protected])
or (isinstance(member.get("email"), str) and "[email protected]" in member.get("email", "").lower())
)
]
return PlaneToolBase.format_success_payload("Successfully retrieved workspace members", filtered_members)
else:
# If data is not a list, return as-is (edge case)
return PlaneToolBase.format_success_payload("Successfully retrieved workspace members", members_data)
else:
return PlaneToolBase.format_error_payload("Failed to get workspace members", result["error"])
# ============================================================================
# OLD MANUAL TOOL DEFINITIONS (COMMENTED OUT - KEPT FOR COMPARISON)
# To rollback: uncomment below and comment out the auto-generation code above
# ============================================================================
@tool
async def members_get_project_members(
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Get all project members (excludes bot users).
Args:
project_id: Project UUID (auto-filled from context if in project chat)
workspace_slug: Workspace slug (auto-filled from context)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"members",
"get_project_members",
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
# Filter out bot users from the results
members_data = result["data"]
if isinstance(members_data, list):
# Filter out users with is_bot=True and bot email patterns
filtered_members = [
member
for member in members_data
if not (
# Check is_bot field if present
member.get("is_bot", False)
# Check for bot email pattern ([email protected])
or (isinstance(member.get("email"), str) and "[email protected]" in member.get("email", "").lower())
)
]
return PlaneToolBase.format_success_payload("Successfully retrieved project members", filtered_members)
else:
# If data is not a list, return as-is (edge case)
return PlaneToolBase.format_success_payload("Successfully retrieved project members", members_data)
else:
return PlaneToolBase.format_error_payload("Failed to get project members", result["error"])
return [members_get_workspace_members, members_get_project_members]
# [102 lines of old code omitted for brevity]
+135 -336
View File
@@ -13,348 +13,147 @@
Modules API tools for Plane module management operations.
"""
from typing import Any
from typing import Dict
from typing import Optional
from langchain_core.tools import tool
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
from pi import logger
# ============================================================================
# MODULES TOOL DEFINITIONS
# ============================================================================
from .base import PlaneToolBase
log = logger.getChild(__name__)
MODULES_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"create": ToolMetadata(
name="modules_create",
description="Create a new module.",
sdk_method="create_module",
returns_entity_type="module",
parameters=[
ToolParameter(name="name", type="str", required=True, description="Module name (required)"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
ToolParameter(name="description", type="Optional[str]", required=False, description="Module description"),
ToolParameter(name="start_date", type="Optional[str]", required=False, description="Start date (YYYY-MM-DD format)"),
ToolParameter(name="target_date", type="Optional[str]", required=False, description="Target completion date (YYYY-MM-DD format)"),
ToolParameter(
name="status", type="Optional[str]", required=False, description="Status: backlog, planned, in-progress, paused, completed, cancelled"
),
ToolParameter(name="lead", type="Optional[str]", required=False, description="Module lead user ID"),
ToolParameter(name="members", type="Optional[list]", required=False, description="List of member user IDs"),
ToolParameter(name="external_id", type="Optional[str]", required=False, description="External identifier"),
ToolParameter(name="external_source", type="Optional[str]", required=False, description="External source name"),
],
),
"list": ToolMetadata(
name="modules_list",
description="List all modules in a project. Requires project_id.",
sdk_method="list_modules",
parameters=[
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"retrieve": ToolMetadata(
name="modules_retrieve",
description="Retrieve details of a specific module.",
sdk_method="retrieve_module",
returns_entity_type="module",
parameters=[
ToolParameter(name="module_id", type="str", required=True, description="Module ID (required)"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"update": ToolMetadata(
name="modules_update",
description="Update module details.",
sdk_method="update_module",
returns_entity_type="module",
parameters=[
ToolParameter(name="module_id", type="str", required=True, description="Module ID (required)"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
ToolParameter(name="name", type="Optional[str]", required=False, description="New module name"),
ToolParameter(name="description", type="Optional[str]", required=False, description="New description"),
ToolParameter(name="start_date", type="Optional[str]", required=False, description="New start date (YYYY-MM-DD)"),
ToolParameter(name="target_date", type="Optional[str]", required=False, description="New target date (YYYY-MM-DD)"),
ToolParameter(name="status", type="Optional[str]", required=False, description="New status"),
ToolParameter(name="lead", type="Optional[str]", required=False, description="New lead user ID"),
ToolParameter(name="members", type="Optional[list]", required=False, description="New members list"),
ToolParameter(name="external_id", type="Optional[str]", required=False, description="External identifier"),
ToolParameter(name="external_source", type="Optional[str]", required=False, description="External source name"),
],
),
"archive": ToolMetadata(
name="modules_archive",
description="Archive a module.",
sdk_method="archive_module",
parameters=[
ToolParameter(name="module_id", type="str", required=True, description="Module ID (required)"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"unarchive": ToolMetadata(
name="modules_unarchive",
description="Unarchive (restore) a module.",
sdk_method="unarchive_module",
parameters=[
ToolParameter(name="module_id", type="str", required=True, description="Module ID (required)"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"list_archived": ToolMetadata(
name="modules_list_archived",
description="List archived modules in a project.",
sdk_method="list_archived_modules",
parameters=[
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"add_work_items": ToolMetadata(
name="modules_add_work_items",
description="Add work items to a module.",
sdk_method="add_module_work_items",
parameters=[
ToolParameter(name="module_id", type="str", required=True, description="Module ID (required)"),
ToolParameter(name="issues", type="list", required=True, description="List of issue IDs to add"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"list_work_items": ToolMetadata(
name="modules_list_work_items",
description="List work items in a module.",
sdk_method="list_module_work_items",
parameters=[
ToolParameter(name="module_id", type="str", required=True, description="Module ID (required)"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"remove_work_item": ToolMetadata(
name="modules_remove_work_item",
description="Remove a work item from a module.",
sdk_method="remove_module_work_item",
parameters=[
ToolParameter(name="module_id", type="str", required=True, description="Module ID (required)"),
ToolParameter(name="issue_id", type="str", required=True, description="Issue ID to remove (required)"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
}
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing module actions
# ============================================================================
# TOOL FACTORY
# ============================================================================
def get_module_tools(method_executor, context):
"""Return LangChain tools for the modules category using method_executor and context."""
@tool
async def modules_create(
name: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
description: Optional[str] = None,
start_date: Optional[str] = None,
target_date: Optional[str] = None,
status: Optional[str] = None,
lead: Optional[str] = None,
members: Optional[list] = None,
external_id: Optional[str] = None,
external_source: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a new module.
Args:
name: Module name (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
description: Module description
start_date: Start date (YYYY-MM-DD format)
target_date: Target completion date (YYYY-MM-DD format)
status: Module status (Input should be one of 'backlog', 'planned', 'in-progress', 'paused', 'completed' or 'cancelled' )
lead: Module lead user ID
members: List of member user IDs
external_id: External system identifier (optional)
external_source: External system source name (optional)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"modules",
"create",
name=name,
project_id=project_id,
workspace_slug=workspace_slug,
description=description,
start_date=start_date,
target_date=target_date,
status=status,
lead=lead,
members=members,
external_id=external_id,
external_source=external_source,
)
if result["success"]:
return await PlaneToolBase.format_success_payload_with_url(f"Successfully created module '{name}'", result["data"], "module", context)
else:
return PlaneToolBase.format_error_payload("Failed to create module", result["error"])
@tool
async def modules_list(project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""List all modules in a project.
Args:
project_id: Project ID (REQUIRED - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
Important: This tool REQUIRES a project_id. If you don't have one, you MUST first:
1. Call search_project_by_name to find the project, OR
2. Call projects_list to see available projects, OR
3. Call ask_for_clarification to ask the user which project to use
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Validate project_id is present
if not project_id:
return PlaneToolBase.format_error_payload(
"Missing required parameter",
"project_id is required to list modules. You MUST first call projects_list to see available projects, OR call ask_for_clarification with disambiguation_options populated by calling projects_list to ask which project the user wants.", # noqa: E501
)
result = await method_executor.execute("modules", "list", project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved modules list", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list modules", result["error"])
@tool
async def modules_add_work_items(module_id: str, issues: list, project_id: Optional[str] = None) -> Dict[str, Any]:
"""Add work items to a module.
Args:
module_id: Module ID (required)
issues: List of issue IDs to add to the module (required)
project_id: Project ID (required - provide from conversation context or previous actions)
"""
# Auto-fill workspace_slug from context (hidden from LLM)
workspace_slug = context.get("workspace_slug")
# Auto-fill project_id from context if not provided
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"modules", "add_work_items", module_id=module_id, issues=issues, project_id=project_id, workspace_slug=workspace_slug
)
if result["success"]:
return PlaneToolBase.format_success_payload(f"Successfully added {len(issues)} work items to module", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to add work items to module", result["error"])
@tool
async def modules_retrieve(module_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""Retrieve details of a specific module.
Args:
module_id: Module ID (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute("modules", "retrieve", module_id=module_id, project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return await PlaneToolBase.format_success_payload_with_url("Successfully retrieved module details", result["data"], "module", context)
else:
return PlaneToolBase.format_error_payload("Failed to retrieve module", result["error"])
@tool
async def modules_update(
module_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
start_date: Optional[str] = None,
target_date: Optional[str] = None,
status: Optional[str] = None,
lead: Optional[str] = None,
members: Optional[list] = None,
external_id: Optional[str] = None,
external_source: Optional[str] = None,
) -> Dict[str, Any]:
"""Update module details.
Args:
module_id: Module ID (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
name: New module name
description: New module description
start_date: New start date (YYYY-MM-DD format)
target_date: New target date (YYYY-MM-DD format)
status: New module status
lead: New module lead user ID
members: New members list (optional)
external_id: External system identifier (optional)
external_source: External system source name (optional)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Build update request object with non-None values only
update_data: Dict[str, Any] = {}
if name is not None:
update_data["name"] = name
if description is not None:
update_data["description"] = description
if start_date is not None:
update_data["start_date"] = start_date
if target_date is not None:
update_data["target_date"] = target_date
if status is not None:
update_data["status"] = status
if lead is not None:
update_data["lead"] = lead
if members is not None:
update_data["members"] = members
if external_id is not None:
update_data["external_id"] = external_id
if external_source is not None:
update_data["external_source"] = external_source
result = await method_executor.execute(
"modules", "update", module_id=module_id, project_id=project_id, workspace_slug=workspace_slug, **update_data
)
if result["success"]:
return await PlaneToolBase.format_success_payload_with_url("Successfully updated module", result["data"], "module", context)
else:
return PlaneToolBase.format_error_payload("Failed to update module", result["error"])
@tool
async def modules_archive(module_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""Archive a module.
Args:
module_id: Module ID (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute("modules", "archive", module_id=module_id, project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully archived module", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to archive module", result["error"])
@tool
async def modules_unarchive(module_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""Unarchive a module.
Args:
module_id: Module ID (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute("modules", "unarchive", module_id=module_id, project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully unarchived module", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to unarchive module", result["error"])
@tool
async def modules_list_archived(project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""List archived modules in a project.
Args:
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute("modules", "list_archived", project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved archived modules list", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list archived modules", result["error"])
@tool
async def modules_list_work_items(module_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""List work items in a module.
Args:
module_id: Module ID (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"modules", "list_work_items", module_id=module_id, project_id=project_id, workspace_slug=workspace_slug
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved module work items", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list module work items", result["error"])
@tool
async def modules_remove_work_item(
module_id: str, issue_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None
) -> Dict[str, Any]:
"""Remove a work item from a module.
Args:
module_id: Module ID (required)
issue_id: Issue ID to remove from the module (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"modules", "remove_work_item", issue_id=issue_id, module_id=module_id, project_id=project_id, workspace_slug=workspace_slug
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully removed work item from module", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to remove work item from module", result["error"])
return [
modules_create,
modules_list,
modules_add_work_items,
modules_retrieve,
modules_update,
modules_archive,
modules_unarchive,
modules_list_archived,
modules_list_work_items,
modules_remove_work_item,
]
"""Return LangChain tools for the modules category using auto-generation from metadata."""
return generate_tools_for_category("modules", method_executor, context, MODULES_TOOL_DEFINITIONS)
+481 -186
View File
@@ -10,204 +10,499 @@
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
"""
Pages API tools for Plane documentation and page management operations.
Pages category - Auto-generated tools from metadata.
This module uses the centralized tool generation system.
Old manual definitions kept below for comparison/rollback safety.
"""
from typing import Any
from typing import Dict
from typing import Optional
from langchain_core.tools import tool
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
from .base import PlaneToolBase
# ============================================================================
# PAGES-SPECIFIC HANDLER FUNCTIONS
# ============================================================================
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing pages actions
async def _pages_pre_handler(
metadata: ToolMetadata,
kwargs: Dict[str, Any],
context: Dict[str, Any],
category: str,
method_key: str,
) -> Dict[str, Any]:
"""Pre-processing handler for pages tools.
Handles:
- Default description_html to name if not provided (API requires this field)
"""
# Default description_html to name if not provided
if "description_html" in [p.name for p in metadata.parameters]:
if not kwargs.get("description_html"):
kwargs["description_html"] = kwargs.get("name", "")
return kwargs
async def _pages_post_handler(
metadata: ToolMetadata,
result: Dict[str, Any],
kwargs: Dict[str, Any],
context: Dict[str, Any],
method_executor: Any,
category: str,
method_key: str,
) -> Dict[str, Any]:
"""Post-processing handler for pages tools.
Handles:
- Inject project field into response data for URL construction
(API response doesn't include project field, but URL builder needs it)
"""
if result["success"] and "project_id" in kwargs and kwargs.get("project_id"):
data = result.get("data", {})
# Using "project" not "project_id" because extract_entity_from_api_response looks for "project"
data["project"] = kwargs["project_id"]
result["data"] = data
return result
# ============================================================================
# PAGES TOOL DEFINITIONS
# ============================================================================
PAGE_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"create_project_page": ToolMetadata(
name="pages_create_project_page",
description="Create a new page in a project",
sdk_method="create_project_page",
returns_entity_type="page",
pre_handler=_pages_pre_handler,
post_handler=_pages_post_handler,
parameters=[
ToolParameter(
name="name",
type="str",
required=True,
description="Page title (required)",
),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (required - provide from conversation context or previous actions)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (provide if known, otherwise auto-detected)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(
name="description_html",
type="Optional[str]",
required=False,
description="Page content in HTML format. If the user asks for 'details about X', "
"generate relevant content. Use empty string if no content is needed. (optional, defaults to page name)",
property_transform="skip",
),
ToolParameter(
name="access",
type="Optional[int]",
required=False,
description="Access level - 0 for public, 1 for private (optional, if not specified the API will use its default)",
property_transform="name",
),
ToolParameter(
name="color",
type="Optional[str]",
required=False,
description="Page color in hex format like #3B82F6 (optional)",
property_transform="name",
),
ToolParameter(
name="logo_props",
type="Optional[dict]",
required=False,
description="Logo properties dict with keys like name, color, type (optional)",
property_transform="name",
),
],
),
"create_workspace_page": ToolMetadata(
name="pages_create_workspace_page",
description="Create a new page in the workspace",
sdk_method="create_workspace_page",
returns_entity_type="page",
pre_handler=_pages_pre_handler,
parameters=[
ToolParameter(
name="name",
type="str",
required=True,
description="Page title (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (provide if known, otherwise auto-detected)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(
name="description_html",
type="Optional[str]",
required=False,
description="Page content in HTML format. If the user asks for 'details about X', "
"generate relevant content. Use empty string if no content is needed. (optional, defaults to page name)",
property_transform="skip",
),
ToolParameter(
name="access",
type="Optional[int]",
required=False,
description="Access level - 0 for public, 1 for private (optional)",
property_transform="name",
),
ToolParameter(
name="color",
type="Optional[str]",
required=False,
description="Page color in hex format like #10B981 (optional)",
property_transform="name",
),
ToolParameter(
name="logo_props",
type="Optional[dict]",
required=False,
description="Logo properties dict with keys like name, color, type (optional)",
property_transform="name",
),
],
),
}
# ============================================================================
# TOOL FACTORY
# ============================================================================
def get_page_tools(method_executor, context):
"""Return LangChain tools for the pages category using method_executor and context."""
"""Return LangChain tools for the pages category using auto-generation from metadata.
Context-aware tool selection:
- In project chat: only project pages (workspace pages are not accessible in project UI)
- In workspace/global chat: unified meta-tool that routes based on project_id
"""
from typing import Optional
from langchain_core.tools import tool
from .base import PlaneToolBase
# Check if we're in a project-specific context
is_project_chat = context.get("is_project_chat", False)
@tool
async def pages_create_project_page(
name: str,
project_id: Optional[str] = None,
description_html: Optional[str] = None,
access: Optional[int] = None,
color: Optional[str] = None,
logo_props: Optional[dict] = None,
) -> Dict[str, Any]:
"""Create a new page in a project.
Args:
name: Page title (required)
project_id: Project ID (required - provide from conversation context or previous actions)
description_html: Page content in HTML format. If the user asks for "details about X", generate relevant content. Use empty string if no content is needed. (optional, defaults to empty string)
access: Access level - 0 for public, 1 for private (optional, if not specified the API will use its default)
color: Page color in hex format like #3B82F6 (optional)
logo_props: Logo properties dict with keys like name, color, type (optional)
Returns:
Success message with page details and URL information
""" # noqa E501
# Auto-fill workspace_slug from context (hidden from LLM)
workspace_slug = context.get("workspace_slug")
# Auto-fill project_id from context if not provided
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# CRITICAL: Provide default empty string if description_html is None
# The Plane API requires this field even if empty
if not description_html:
description_html = name
result = await method_executor.execute(
"pages",
"create_project_page",
name=name,
project_id=project_id,
workspace_slug=workspace_slug,
description_html=description_html,
access=access,
color=color,
logo_props=logo_props,
)
if result.get("success"):
data = result.get("data", {})
# CRITICAL: Inject project into response data for URL construction
# The API response doesn't include project field, so we need to add it
# Using "project" not "project_id" because extract_entity_from_api_response looks for "project"
if project_id:
data["project"] = project_id
return await PlaneToolBase.format_success_payload_with_url(f"Project page '{name}' created successfully", data, "page", context)
else:
error_msg = result.get("error", "Unknown error occurred")
return PlaneToolBase.format_error_payload("Failed to create project page", error_msg)
@tool
async def pages_create_workspace_page(
name: str,
description_html: Optional[str] = None,
access: Optional[int] = None,
color: Optional[str] = None,
logo_props: Optional[dict] = None,
) -> Dict[str, Any]:
"""Create a new page in the workspace.
Args:
name: Page title (required)
description_html: Page content in HTML format. If the user asks for "details about X", generate relevant content. Use empty string if no content is needed. (optional, defaults to empty string)
access: Access level - 0 for public, 1 for private (optional, if not specified the API will use its default)
color: Page color in hex format like #10B981 (optional)
logo_props: Logo properties dict with keys like name, color, type (optional)
Returns:
Success message with page details and URL information
""" # noqa E501
# Auto-fill workspace_slug from context (hidden from LLM)
workspace_slug = context.get("workspace_slug")
# CRITICAL: Provide default empty string if description_html is None
# The Plane API requires this field even if empty
if not description_html:
description_html = name
result = await method_executor.execute(
"pages",
"create_workspace_page",
name=name,
workspace_slug=workspace_slug,
description_html=description_html,
access=access,
color=color,
logo_props=logo_props,
)
if result.get("success"):
data = result.get("data", {})
return await PlaneToolBase.format_success_payload_with_url(f"Workspace page '{name}' created successfully", data, "page", context)
else:
error_msg = result.get("error", "Unknown error occurred")
return PlaneToolBase.format_error_payload("Failed to create workspace page", error_msg)
# In workspace/global context, expose a single tool that forces clarification for scope
@tool
async def pages_create_page(
name: str,
project_id: str,
description_html: Optional[str] = None,
access: Optional[int] = None,
color: Optional[str] = None,
logo_props: Optional[dict] = None,
) -> Dict[str, Any]:
"""Create a new page either in a project or at the workspace level.
Args:
name: Page title (required)
project_id: Project UUID where the page should be created. If user hasn't specified a project, leave this empty or use 'NEEDS_CLARIFICATION' to trigger project selection. (required)
description_html: Page content in HTML format. If the user asks for "details about X", generate relevant content. Use empty string if no content is needed. (optional, defaults to empty string)
access: Access level - 0 for public, 1 for private (optional)
color: Page color in hex format (optional)
logo_props: Logo properties dict with keys like name, color, type (optional)
""" # noqa E501
workspace_slug = context.get("workspace_slug")
# CRITICAL: Provide default empty string if description_html is None
# The Plane API requires this field even if empty
if not description_html:
description_html = name
if project_id == "__workspace_scope__":
# Create at workspace level
result = await method_executor.execute(
"pages",
"create_workspace_page",
name=name,
workspace_slug=workspace_slug,
description_html=description_html,
access=access,
color=color,
logo_props=logo_props,
)
scope_label = "Workspace"
else:
# Create in a specific project
result = await method_executor.execute(
"pages",
"create_project_page",
name=name,
project_id=project_id,
workspace_slug=workspace_slug,
description_html=description_html,
access=access,
color=color,
logo_props=logo_props,
)
scope_label = "Project"
if result.get("success"):
data = result.get("data", {})
# CRITICAL: Inject project into response data for URL construction
# The API response doesn't include project field, so we need to add it
# Using "project" not "project_id" because extract_entity_from_api_response looks for "project"
# for the URL builder to construct the correct project page URL
if scope_label == "Project" and project_id != "__workspace_scope__":
data["project"] = project_id
return await PlaneToolBase.format_success_payload_with_url(f"{scope_label} page '{name}' created successfully", data, "page", context)
else:
error_msg = result.get("error", "Unknown error occurred")
return PlaneToolBase.format_error_payload(f"Failed to create {scope_label.lower()} page", error_msg)
# Return tools based on context:
# - In project chat: only project pages (workspace pages are not accessible in project UI)
# - In workspace/global chat: consolidated tool that requires scope selection via clarification
if is_project_chat:
return [pages_create_project_page]
# Return only project page tool
tools = generate_tools_for_category(
category="pages",
method_executor=method_executor,
context=context,
tool_definitions={"create_project_page": PAGE_TOOL_DEFINITIONS["create_project_page"]},
)
else:
return [pages_create_page]
# In workspace/global context, create a meta-tool that routes to the correct SDK method
@tool
async def pages_create_page(
name: str,
project_id: str,
description_html: Optional[str] = None,
access: Optional[int] = None,
color: Optional[str] = None,
logo_props: Optional[dict] = None,
) -> Dict[str, Any]:
"""Create a new page either in a project or at the workspace level.
Args:
name: Page title (required)
project_id: Project UUID where the page should be created. If user hasn't specified a project,
leave this empty or use 'NEEDS_CLARIFICATION' to trigger project selection. (required)
description_html: Page content in HTML format. If the user asks for 'details about X',
generate relevant content. Use empty string if no content is needed.
access: Access level - 0 for public, 1 for private (optional)
color: Page color in hex format (optional)
logo_props: Logo properties dict with keys like name, color, type (optional)
"""
workspace_slug = context.get("workspace_slug")
# CRITICAL: Provide default empty string if description_html is None
# The Plane API requires this field even if empty
if not description_html:
description_html = name
if project_id == "__workspace_scope__":
# Create at workspace level (Wiki)
result = await method_executor.execute(
"pages",
"create_workspace_page",
name=name,
workspace_slug=workspace_slug,
description_html=description_html,
access=access,
color=color,
logo_props=logo_props,
)
scope_label = "Workspace"
else:
# Create in a specific project (Plane project pages)
result = await method_executor.execute(
"pages",
"create_project_page",
name=name,
project_id=project_id,
workspace_slug=workspace_slug,
description_html=description_html,
access=access,
color=color,
logo_props=logo_props,
)
scope_label = "Project"
if result.get("success"):
data = result.get("data", {})
# CRITICAL: Inject project into response data for URL construction
# The API response doesn't include project field, so we need to add it
# Using "project" not "project_id" because extract_entity_from_api_response looks for "project"
# for the URL builder to construct the correct project page URL
if scope_label == "Project" and project_id != "__workspace_scope__":
data["project"] = project_id
return await PlaneToolBase.format_success_payload_with_url(f"{scope_label} page '{name}' created successfully", data, "page", context)
else:
error_msg = result.get("error", "Unknown error occurred")
return PlaneToolBase.format_error_payload(f"Failed to create {scope_label.lower()} page", error_msg)
tools = [pages_create_page] # type: ignore[list-item]
return tools
# ============================================================================
# OLD MANUAL TOOL DEFINITIONS (COMMENTED FOR REFERENCE)
# ============================================================================
# """
# Pages API tools for Plane documentation and page management operations.
# """
#
# from typing import Any
# from typing import Dict
# from typing import Optional
#
# from langchain_core.tools import tool
#
# from .base import PlaneToolBase
#
# # Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# # Returns LangChain tools implementing pages actions
#
#
# def get_page_tools(method_executor, context):
# """Return LangChain tools for the pages category using method_executor and context."""
#
# # Check if we're in a project-specific context
# is_project_chat = context.get("is_project_chat", False)
#
# @tool
# async def pages_create_project_page(
# name: str,
# project_id: Optional[str] = None,
# description_html: Optional[str] = None,
# access: Optional[int] = None,
# color: Optional[str] = None,
# logo_props: Optional[dict] = None,
# ) -> Dict[str, Any]:
# """Create a new page in a project.
#
# Args:
# name: Page title (required)
# project_id: Project ID (required - provide from conversation context or previous actions)
# description_html: Page content in HTML format. If the user asks for "details about X",
# generate relevant content. Use empty string if no content is needed.
# access: Access level - 0 for public, 1 for private (optional, if not specified the API will use its default)
# color: Page color in hex format like #3B82F6 (optional)
# logo_props: Logo properties dict with keys like name, color, type (optional)
#
# Returns:
# Success message with page details and URL information
# """ # noqa E501
# # Auto-fill workspace_slug from context (hidden from LLM)
# workspace_slug = context.get("workspace_slug")
#
# # Auto-fill project_id from context if not provided
# if project_id is None and "project_id" in context:
# project_id = context["project_id"]
#
# # CRITICAL: Provide default empty string if description_html is None
# # The Plane API requires this field even if empty
# if not description_html:
# description_html = name
#
# result = await method_executor.execute(
# "pages",
# "create_project_page",
# name=name,
# project_id=project_id,
# workspace_slug=workspace_slug,
# description_html=description_html,
# access=access,
# color=color,
# logo_props=logo_props,
# )
#
# if result.get("success"):
# data = result.get("data", {})
# # CRITICAL: Inject project into response data for URL construction
# # The API response doesn't include project field, so we need to add it
# # Using "project" not "project_id" because extract_entity_from_api_response looks for "project"
# if project_id:
# data["project"] = project_id
# return await PlaneToolBase.format_success_payload_with_url(f"Project page '{name}' created successfully", data, "page", context)
# else:
# error_msg = result.get("error", "Unknown error occurred")
# return PlaneToolBase.format_error_payload("Failed to create project page", error_msg)
#
# @tool
# async def pages_create_workspace_page(
# name: str,
# description_html: Optional[str] = None,
# access: Optional[int] = None,
# color: Optional[str] = None,
# logo_props: Optional[dict] = None,
# ) -> Dict[str, Any]:
# """Create a new page in the workspace.
#
# Args:
# name: Page title (required)
# description_html: Page content in HTML format. If the user asks for "details about X",
# generate relevant content. Use empty string if no content is needed.
# access: Access level - 0 for public, 1 for private (optional, if not specified the API will use its default)
# color: Page color in hex format like #10B981 (optional)
# logo_props: Logo properties dict with keys like name, color, type (optional)
#
# Returns:
# Success message with page details and URL information
# """ # noqa E501
# # Auto-fill workspace_slug from context (hidden from LLM)
# workspace_slug = context.get("workspace_slug")
#
# # CRITICAL: Provide default empty string if description_html is None
# # The Plane API requires this field even if empty
# if not description_html:
# description_html = name
#
# result = await method_executor.execute(
# "pages",
# "create_workspace_page",
# name=name,
# workspace_slug=workspace_slug,
# description_html=description_html,
# access=access,
# color=color,
# logo_props=logo_props,
# )
#
# if result.get("success"):
# data = result.get("data", {})
# return await PlaneToolBase.format_success_payload_with_url(f"Workspace page '{name}' created successfully", data, "page", context)
# else:
# error_msg = result.get("error", "Unknown error occurred")
# return PlaneToolBase.format_error_payload("Failed to create workspace page", error_msg)
#
# # In workspace/global context, expose a single tool that forces clarification for scope
# @tool
# async def pages_create_page(
# name: str,
# project_id: str,
# description_html: Optional[str] = None,
# access: Optional[int] = None,
# color: Optional[str] = None,
# logo_props: Optional[dict] = None,
# ) -> Dict[str, Any]:
# """Create a new page either in a project or at the workspace level.
#
# Args:
# name: Page title (required)
# project_id: Project UUID where the page should be created. If user hasn't specified a project,
# leave this empty or use 'NEEDS_CLARIFICATION' to trigger project selection. (required)
# description_html: Page content in HTML format. If the user asks for "details about X",
# generate relevant content. Use empty string if no content is needed.
# access: Access level - 0 for public, 1 for private (optional)
# color: Page color in hex format (optional)
# logo_props: Logo properties dict with keys like name, color, type (optional)
# """ # noqa E501
# workspace_slug = context.get("workspace_slug")
#
# # CRITICAL: Provide default empty string if description_html is None
# # The Plane API requires this field even if empty
# if not description_html:
# description_html = name
#
# if project_id == "__workspace_scope__":
# # Create at workspace level
# result = await method_executor.execute(
# "pages",
# "create_workspace_page",
# name=name,
# workspace_slug=workspace_slug,
# description_html=description_html,
# access=access,
# color=color,
# logo_props=logo_props,
# )
# scope_label = "Workspace"
# else:
# # Create in a specific project
# result = await method_executor.execute(
# "pages",
# "create_project_page",
# name=name,
# project_id=project_id,
# workspace_slug=workspace_slug,
# description_html=description_html,
# access=access,
# color=color,
# logo_props=logo_props,
# )
# scope_label = "Project"
#
# if result.get("success"):
# data = result.get("data", {})
# # CRITICAL: Inject project into response data for URL construction
# # The API response doesn't include project field, so we need to add it
# # Using "project" not "project_id" because extract_entity_from_api_response looks for "project"
# # for the URL builder to construct the correct project page URL
# if scope_label == "Project" and project_id != "__workspace_scope__":
# data["project"] = project_id
# return await PlaneToolBase.format_success_payload_with_url(f"{scope_label} page '{name}' created successfully", data, "page", context)
# else:
# error_msg = result.get("error", "Unknown error occurred")
# return PlaneToolBase.format_error_payload(f"Failed to create {scope_label.lower()} page", error_msg)
#
# # Return tools based on context:
# # - In project chat: only project pages (workspace pages are not accessible in project UI)
# # - In workspace/global chat: consolidated tool that requires scope selection via clarification
#
# if is_project_chat:
# return [pages_create_project_page]
# else:
# return [pages_create_page]
File diff suppressed because it is too large Load Diff
+205 -489
View File
@@ -13,498 +13,214 @@
Properties API tools for Plane custom property management.
"""
from typing import Any
from typing import Dict
from typing import Optional
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
from langchain_core.tools import tool
# ============================================================================
# TOOL METADATA DEFINITIONS
# ============================================================================
from .base import PlaneToolBase
PROPERTIES_TOOL_DEFINITIONS = {
# ==========================================================================
# Core Property CRUD
# ==========================================================================
"create": ToolMetadata(
name="properties_create",
description="Create a new work item property (custom field) for a specific work item type.",
sdk_method="create_property",
parameters=[
ToolParameter(name="display_name", type="str", required=True, description="Display name for the property (required)"),
ToolParameter(
name="property_type",
type="str",
required=True,
description="Type of property: TEXT, DATETIME, DECIMAL, BOOLEAN, OPTION, RELATION, URL, EMAIL, FILE",
),
ToolParameter(name="type_id", type="str", required=True, description="Work item type ID this property belongs to (required)"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
ToolParameter(
name="relation_type",
type="Optional[str]",
required=False,
description="Relation type for RELATION properties: ONE_TO_ONE, ONE_TO_MANY, MANY_TO_ONE, MANY_TO_MANY",
),
ToolParameter(name="description", type="Optional[str]", required=False, description="Description of the property"),
ToolParameter(name="is_required", type="Optional[bool]", required=False, description="Whether this property is required for work items"),
ToolParameter(name="default_value", type="Optional[list]", required=False, description="List of default values for the property"),
ToolParameter(
name="settings",
type="Optional[dict]",
required=False,
description="Property settings (TextAttributeSettings for TEXT, DateAttributeSettings for DATETIME)",
),
ToolParameter(name="is_active", type="Optional[bool]", required=False, description="Whether the property is active"),
ToolParameter(name="is_multi", type="Optional[bool]", required=False, description="Whether this property supports multiple values"),
ToolParameter(name="validation_rules", type="Optional[dict]", required=False, description="Validation rules for the property"),
ToolParameter(name="options", type="Optional[list]", required=False, description="List of options for OPTION type properties"),
ToolParameter(
name="external_source", type="Optional[str]", required=False, description="External system source identifier (e.g., 'github', 'jira')"
),
ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system's identifier for this property"),
],
returns_entity_type="property",
),
"list": ToolMetadata(
name="properties_list",
description="List all work item properties (custom fields) for a specific work item type.",
sdk_method="list_properties",
parameters=[
ToolParameter(name="type_id", type="str", required=True, description="Work item type ID to list properties for"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"retrieve": ToolMetadata(
name="properties_retrieve",
description="Get a single work item property by ID.",
sdk_method="retrieve_property",
parameters=[
ToolParameter(name="property_id", type="str", required=True, description="Property ID to retrieve"),
ToolParameter(name="type_id", type="str", required=True, description="Work item type ID this property belongs to"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"update": ToolMetadata(
name="properties_update",
description="Update work item property details.",
sdk_method="update_property",
parameters=[
ToolParameter(name="property_id", type="str", required=True, description="Property ID to update"),
ToolParameter(name="type_id", type="str", required=True, description="Work item type ID this property belongs to"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
ToolParameter(name="display_name", type="Optional[str]", required=False, description="Display name for the property"),
ToolParameter(
name="property_type",
type="Optional[str]",
required=False,
description="Type of property: TEXT, DATETIME, DECIMAL, BOOLEAN, OPTION, RELATION, URL, EMAIL, FILE",
),
ToolParameter(name="relation_type", type="Optional[str]", required=False, description="Relation type for RELATION properties"),
ToolParameter(name="description", type="Optional[str]", required=False, description="Description of the property"),
ToolParameter(name="is_required", type="Optional[bool]", required=False, description="Whether this property is required"),
ToolParameter(name="default_value", type="Optional[list]", required=False, description="List of default values"),
ToolParameter(name="settings", type="Optional[dict]", required=False, description="Property settings"),
ToolParameter(name="is_active", type="Optional[bool]", required=False, description="Whether the property is active"),
ToolParameter(name="is_multi", type="Optional[bool]", required=False, description="Whether this property supports multiple values"),
ToolParameter(name="validation_rules", type="Optional[dict]", required=False, description="Validation rules"),
ToolParameter(name="external_source", type="Optional[str]", required=False, description="External system source identifier"),
ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system's identifier"),
],
returns_entity_type="property",
),
"delete": ToolMetadata(
name="properties_delete",
description="Delete a work item property.",
sdk_method="delete_property",
parameters=[
ToolParameter(name="property_id", type="str", required=True, description="Property ID to delete"),
ToolParameter(name="type_id", type="str", required=True, description="Work item type ID this property belongs to"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
returns_entity_type="property",
),
# ==========================================================================
# Property Options (for OPTION type properties)
# ==========================================================================
"create_option": ToolMetadata(
name="properties_create_option",
description="Create a new option for an OPTION type property.",
sdk_method="create_property_option",
parameters=[
ToolParameter(name="property_id", type="str", required=True, description="Property ID to add option to"),
ToolParameter(name="type_id", type="str", required=True, description="Work item type ID"),
ToolParameter(name="name", type="str", required=True, description="Option display name"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
ToolParameter(name="value", type="Optional[str]", required=False, description="Option value (defaults to name if not provided)"),
],
returns_entity_type="property_option",
),
"list_options": ToolMetadata(
name="properties_list_options",
description="List all options for an OPTION type property.",
sdk_method="list_property_options",
parameters=[
ToolParameter(name="property_id", type="str", required=True, description="Property ID to list options for"),
ToolParameter(name="type_id", type="str", required=True, description="Work item type ID"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"retrieve_option": ToolMetadata(
name="properties_retrieve_option",
description="Get a property option by ID.",
sdk_method="retrieve_property_option",
parameters=[
ToolParameter(name="property_id", type="str", required=True, description="Property ID"),
ToolParameter(name="type_id", type="str", required=True, description="Work item type ID"),
ToolParameter(name="option_id", type="str", required=True, description="Option ID to retrieve"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
),
"update_option": ToolMetadata(
name="properties_update_option",
description="Update a property option.",
sdk_method="update_property_option",
parameters=[
ToolParameter(name="property_id", type="str", required=True, description="Property ID"),
ToolParameter(name="type_id", type="str", required=True, description="Work item type ID"),
ToolParameter(name="option_id", type="str", required=True, description="Option ID to update"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
ToolParameter(name="name", type="Optional[str]", required=False, description="Option display name"),
ToolParameter(name="value", type="Optional[str]", required=False, description="Option value"),
],
returns_entity_type="property_option",
),
"delete_option": ToolMetadata(
name="properties_delete_option",
description="Delete a property option.",
sdk_method="delete_property_option",
parameters=[
ToolParameter(name="property_id", type="str", required=True, description="Property ID"),
ToolParameter(name="type_id", type="str", required=True, description="Work item type ID"),
ToolParameter(name="option_id", type="str", required=True, description="Option ID to delete"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
returns_entity_type="property_option",
),
# ==========================================================================
# Property Values (values set on work items)
# ==========================================================================
"create_value": ToolMetadata(
name="properties_create_value",
description='Set a property value on a work item. For simple types (TEXT), provide the value directly. For single-select (OPTION), provide the Option ID. For multi-select, provide a JSON-formatted list of Option IDs (e.g., \'["uuid1", "uuid2"]\').', # noqa: E501
sdk_method="create_property_value",
parameters=[
ToolParameter(name="property_id", type="str", required=True, description="Property ID"),
ToolParameter(name="type_id", type="str", required=True, description="Work item type ID"),
ToolParameter(name="issue_id", type="str", required=True, description="Work item ID to set value on"),
ToolParameter(name="value", type="str", required=True, description="Property value (use Option ID for OPTION types)"),
ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True),
],
returns_entity_type="property_value",
),
}
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing property actions
# ============================================================================
# TOOL FACTORY
# ============================================================================
def get_property_tools(method_executor, context):
"""Return LangChain tools for the properties category using method_executor and context."""
"""Get all Properties API tools."""
@tool
async def properties_create(
display_name: str,
property_type: str,
type_id: str,
relation_type: Optional[str] = None,
description: Optional[str] = None,
is_required: Optional[bool] = None,
default_value: Optional[list] = None,
settings: Optional[dict] = None,
is_active: Optional[bool] = None,
is_multi: Optional[bool] = None,
validation_rules: Optional[dict] = None,
external_source: Optional[str] = None,
external_id: Optional[str] = None,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a new work item property.
Args:
display_name: Display name for the property (required)
property_type: Type of property - TEXT, DATETIME, DECIMAL, BOOLEAN, OPTION, RELATION, URL, EMAIL, FILE (required)
type_id: Work item type ID this property belongs to (required)
relation_type: Relation type for RELATION properties - ONE_TO_ONE, ONE_TO_MANY, MANY_TO_ONE, MANY_TO_MANY
description: Description of the property
is_required: Whether this property is required for work items
default_value: List of default values for the property
settings: Property settings (TextAttributeSettings for TEXT, DateAttributeSettings for DATETIME)
is_active: Whether the property is active
is_multi: Whether this property supports multiple values
validation_rules: Validation rules for the property
external_source: External system source identifier (e.g., 'github', 'jira')
external_id: External system's identifier for this property
project_id: Project ID (auto-filled from context if not provided)
workspace_slug: Workspace slug (auto-filled from context if not provided)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"properties",
"create",
display_name=display_name,
property_type=property_type,
type_id=type_id,
relation_type=relation_type,
description=description,
is_required=is_required,
default_value=default_value,
settings=settings,
is_active=is_active,
is_multi=is_multi,
validation_rules=validation_rules,
external_source=external_source,
external_id=external_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload(f"Successfully created property '{display_name}'", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to create property", result["error"])
@tool
async def properties_list(
type_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""List all work item properties for a specific work item type.
Args:
type_id: Work item type ID to list properties for (required)
project_id: Project ID (auto-filled from context if not provided)
workspace_slug: Workspace slug (auto-filled from context if not provided)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"properties",
"list",
type_id=type_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved properties list", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list properties", result["error"])
@tool
async def properties_retrieve(
property_id: str,
type_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Get a single work item property by ID.
Args:
property_id: Property ID to retrieve (required)
type_id: Work item type ID this property belongs to (required)
project_id: Project ID (auto-filled from context if not provided)
workspace_slug: Workspace slug (auto-filled from context if not provided)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"properties",
"retrieve",
property_id=property_id,
type_id=type_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved property", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to retrieve property", result["error"])
@tool
async def properties_update(
property_id: str,
type_id: str,
display_name: Optional[str] = None,
property_type: Optional[str] = None,
relation_type: Optional[str] = None,
description: Optional[str] = None,
is_required: Optional[bool] = None,
default_value: Optional[list] = None,
settings: Optional[dict] = None,
is_active: Optional[bool] = None,
is_multi: Optional[bool] = None,
validation_rules: Optional[dict] = None,
external_source: Optional[str] = None,
external_id: Optional[str] = None,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Update work item property details.
Args:
property_id: Property ID to update (required)
type_id: Work item type ID this property belongs to (required)
display_name: Display name for the property
property_type: Type of property - TEXT, DATETIME, DECIMAL, BOOLEAN, OPTION, RELATION, URL, EMAIL, FILE
relation_type: Relation type for RELATION properties - ONE_TO_ONE, ONE_TO_MANY, MANY_TO_ONE, MANY_TO_MANY
description: Description of the property
is_required: Whether this property is required for work items
default_value: List of default values for the property
settings: Property settings (TextAttributeSettings for TEXT, DateAttributeSettings for DATETIME)
is_active: Whether the property is active
is_multi: Whether this property supports multiple values
validation_rules: Validation rules for the property
external_source: External system source identifier (e.g., 'github', 'jira')
external_id: External system's identifier for this property
project_id: Project ID (auto-filled from context if not provided)
workspace_slug: Workspace slug (auto-filled from context if not provided)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Build update data
update_data: dict[str, Any] = {}
if display_name is not None:
update_data["display_name"] = display_name
if property_type is not None:
update_data["property_type"] = property_type
if relation_type is not None:
update_data["relation_type"] = relation_type
if description is not None:
update_data["description"] = description
if is_required is not None:
update_data["is_required"] = is_required
if default_value is not None:
update_data["default_value"] = default_value
if settings is not None:
update_data["settings"] = settings
if is_active is not None:
update_data["is_active"] = is_active
if is_multi is not None:
update_data["is_multi"] = is_multi
if validation_rules is not None:
update_data["validation_rules"] = validation_rules
if external_source is not None:
update_data["external_source"] = external_source
if external_id is not None:
update_data["external_id"] = external_id
result = await method_executor.execute(
"properties",
"update",
property_id=property_id,
type_id=type_id,
project_id=project_id,
workspace_slug=workspace_slug,
**update_data,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully updated property", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to update property", result["error"])
@tool
async def properties_delete(
property_id: str,
type_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Delete a work item property.
Args:
property_id: Property ID to delete (required)
type_id: Work item type ID this property belongs to (required)
project_id: Project ID (auto-filled from context if not provided)
workspace_slug: Workspace slug (auto-filled from context if not provided)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"properties",
"delete",
property_id=property_id,
type_id=type_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully deleted property", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to delete property", result["error"])
@tool
async def properties_create_option(
property_id: str,
type_id: str,
name: str,
value: Optional[str] = None,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a property option."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"properties",
"create_option",
property_id=property_id,
type_id=type_id,
name=name,
value=value,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully created property option", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to create property option", result["error"])
@tool
async def properties_create_value(
property_id: str,
type_id: str,
issue_id: str,
value: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a property value."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"properties",
"create_value",
property_id=property_id,
type_id=type_id,
issue_id=issue_id,
value=value,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully created property value", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to create property value", result["error"])
@tool
async def properties_list_options(
property_id: str,
type_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""List property options."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"properties",
"list_options",
property_id=property_id,
type_id=type_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved property options", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list property options", result["error"])
@tool
async def properties_list_values(
type_id: str,
issue_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""List property values."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"properties",
"list_values",
type_id=type_id,
issue_id=issue_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved property values", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list property values", result["error"])
@tool
async def properties_retrieve_option(
property_id: str,
type_id: str,
option_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Get property option by ID."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"properties",
"retrieve_option",
property_id=property_id,
type_id=type_id,
option_id=option_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved property option", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to retrieve property option", result["error"])
@tool
async def properties_update_option(
property_id: str,
type_id: str,
option_id: str,
name: Optional[str] = None,
value: Optional[str] = None,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Update property option."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Build update data
update_data = {}
if name is not None:
update_data["name"] = name
if value is not None:
update_data["value"] = value
result = await method_executor.execute(
"properties",
"update_option",
property_id=property_id,
type_id=type_id,
option_id=option_id,
project_id=project_id,
workspace_slug=workspace_slug,
**update_data,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully updated property option", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to update property option", result["error"])
@tool
async def properties_delete_option(
property_id: str,
type_id: str,
option_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Delete property option."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"properties",
"delete_option",
property_id=property_id,
type_id=type_id,
option_id=option_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully deleted property option", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to delete property option", result["error"])
return [
properties_create,
properties_list,
properties_retrieve,
properties_update,
properties_delete,
properties_create_option,
properties_create_value,
properties_list_options,
properties_list_values,
properties_retrieve_option,
properties_update_option,
properties_delete_option,
]
"""Return LangChain tools for the properties category using auto-generation from metadata."""
return generate_tools_for_category("properties", method_executor, context, PROPERTIES_TOOL_DEFINITIONS)
+356 -185
View File
@@ -11,201 +11,372 @@
"""
States API tools for Plane workflow state management.
MIGRATED TO AUTO-GENERATED TOOLS
Tool metadata defined in this file (STATE_TOOL_DEFINITIONS) for modularity.
Old manual definitions kept below for comparison/rollback safety.
"""
from typing import Any
from typing import Dict
from typing import Optional
from langchain_core.tools import tool
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
from .base import PlaneToolBase
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing state actions
# Tool metadata for states category
STATE_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"create": ToolMetadata(
name="states_create",
description="Create a new workflow state",
sdk_method="create_state",
returns_entity_type="state",
parameters=[
ToolParameter(name="name", type="str", required=True, description="State name (required)"),
ToolParameter(name="color", type="str", required=True, description="State color in hex format (required)"),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (required - provide from conversation context or previous actions)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (provide if known, otherwise auto-detected)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(name="description", type="Optional[str]", required=False, description="State description", property_transform="name"),
ToolParameter(
name="group",
type="Optional[str]",
required=False,
description="State group (backlog, unstarted, started, completed, cancelled)",
property_transform="name",
),
ToolParameter(name="sequence", type="Optional[int]", required=False, description="Display sequence order", property_transform="name"),
ToolParameter(
name="is_triage", type="Optional[bool]", required=False, description="Whether this is a triage state", property_transform="name"
),
ToolParameter(
name="default", type="Optional[bool]", required=False, description="Whether this is the default state", property_transform="name"
),
ToolParameter(
name="external_source",
type="Optional[str]",
required=False,
description='External source identifier (e.g., "jira")',
property_transform="name",
),
ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system ID", property_transform="name"),
],
),
"list": ToolMetadata(
name="states_list",
description="List all workflow states in a project",
sdk_method="list_states",
parameters=[
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (required - provide from conversation context or previous actions)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (provide if known, otherwise auto-detected)",
auto_fill_from_context=True,
property_transform="skip",
),
],
),
"retrieve": ToolMetadata(
name="states_retrieve",
description="Retrieve details of a specific workflow state",
sdk_method="retrieve_state",
parameters=[
ToolParameter(name="state_id", type="str", required=True, description="State ID (required)"),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (required - provide from conversation context or previous actions)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (provide if known, otherwise auto-detected)",
auto_fill_from_context=True,
property_transform="skip",
),
],
),
"update": ToolMetadata(
name="states_update",
description="Update an existing workflow state",
sdk_method="update_state",
returns_entity_type="state",
parameters=[
ToolParameter(name="state_id", type="str", required=True, description="State ID (required)"),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (required - provide from conversation context or previous actions)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (provide if known, otherwise auto-detected)",
auto_fill_from_context=True,
property_transform="skip",
),
ToolParameter(name="name", type="Optional[str]", required=False, description="New state name", property_transform="name"),
ToolParameter(name="color", type="Optional[str]", required=False, description="New state color", property_transform="name"),
ToolParameter(name="description", type="Optional[str]", required=False, description="New state description", property_transform="name"),
ToolParameter(name="sequence", type="Optional[int]", required=False, description="Display sequence order", property_transform="name"),
ToolParameter(
name="group",
type="Optional[str]",
required=False,
description="State group (backlog, unstarted, started, completed, cancelled)",
property_transform="name",
),
ToolParameter(
name="is_triage", type="Optional[bool]", required=False, description="Whether this is a triage state", property_transform="name"
),
ToolParameter(
name="default", type="Optional[bool]", required=False, description="Whether this is the default state", property_transform="name"
),
ToolParameter(
name="external_source",
type="Optional[str]",
required=False,
description='External source identifier (e.g., "jira")',
property_transform="name",
),
ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system ID", property_transform="name"),
],
),
}
def get_state_tools(method_executor, context):
"""Return LangChain tools for the states category using method_executor and context."""
"""Return LangChain tools for the states category using auto-generation from metadata."""
@tool
async def states_create(
name: str,
color: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
description: Optional[str] = None,
group: Optional[str] = None,
sequence: Optional[int] = None,
is_triage: Optional[bool] = None,
default: Optional[bool] = None,
external_source: Optional[str] = None,
external_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a new workflow state.
state_tools = generate_tools_for_category(
category="states",
method_executor=method_executor,
context=context,
tool_definitions=STATE_TOOL_DEFINITIONS,
)
Args:
name: State name (required)
color: State color in hex format (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
description: State description
group: State group (backlog, unstarted, started, completed, cancelled)
sequence: Display sequence order
is_triage: Whether this is a triage state
default: Whether this is the default state
external_source: External source identifier (e.g., "jira")
external_id: External system ID
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
return state_tools
result = await method_executor.execute(
"states",
"create",
name=name,
color=color,
project_id=project_id,
workspace_slug=workspace_slug,
description=description,
group=group,
sequence=sequence,
is_triage=is_triage,
default=default,
external_source=external_source,
external_id=external_id,
)
if result["success"]:
return await PlaneToolBase.format_success_payload_with_url(f"Successfully created state '{name}'", result["data"], "state", context)
else:
return PlaneToolBase.format_error_payload("Failed to create state", result["error"])
# ============================================================================
# OLD MANUAL TOOL DEFINITIONS (COMMENTED OUT - KEPT FOR COMPARISON)
# To rollback: uncomment below and comment out the auto-generation code above
# ============================================================================
@tool
async def states_list(
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""List all workflow states in a project."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"states",
"list",
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved states list", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list states", result["error"])
@tool
async def states_retrieve(
state_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Retrieve details of a specific workflow state.
Args:
state_id: State ID (required)
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"states",
"retrieve",
state_id=state_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved state details", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to retrieve state", result["error"])
@tool
async def states_update(
state_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
name: Optional[str] = None,
color: Optional[str] = None,
description: Optional[str] = None,
sequence: Optional[int] = None,
group: Optional[str] = None,
is_triage: Optional[bool] = None,
default: Optional[bool] = None,
external_source: Optional[str] = None,
external_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Update an existing workflow state.
Args:
state_id: State ID (required)
name: New state name
color: New state color
description: New state description
sequence: Display sequence order
group: State group (backlog, unstarted, started, completed, cancelled)
is_triage: Whether this is a triage state
default: Whether this is the default state
external_source: External source identifier (e.g., "jira")
external_id: External system ID
project_id: Project ID (required - provide from conversation context or previous actions)
workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Build update data with only non-None values
update_data = {
k: v
for k, v in {
"name": name,
"color": color,
"description": description,
"sequence": sequence,
"group": group,
"is_triage": is_triage,
"default": default,
"external_source": external_source,
"external_id": external_id,
}.items()
if v is not None
}
result = await method_executor.execute(
"states",
"update",
state_id=state_id,
project_id=project_id,
workspace_slug=workspace_slug,
**update_data,
)
if result["success"]:
return await PlaneToolBase.format_success_payload_with_url(f"Successfully updated state '{state_id}'", result["data"], "state", context)
else:
return PlaneToolBase.format_error_payload("Failed to update state", result["error"])
return [states_create, states_list, states_retrieve, states_update]
# from typing import Optional
# from langchain_core.tools import tool
# from .base import PlaneToolBase
#
# def get_state_tools(method_executor, context):
# """Return LangChain tools for the states category using method_executor and context."""
#
# @tool
# async def states_create(
# name: str,
# color: str,
# project_id: Optional[str] = None,
# workspace_slug: Optional[str] = None,
# description: Optional[str] = None,
# group: Optional[str] = None,
# sequence: Optional[int] = None,
# is_triage: Optional[bool] = None,
# default: Optional[bool] = None,
# external_source: Optional[str] = None,
# external_id: Optional[str] = None,
# ) -> Dict[str, Any]:
# """Create a new workflow state.
#
# Args:
# name: State name (required)
# color: State color in hex format (required)
# project_id: Project ID (required - provide from conversation context or previous actions)
# workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
# description: State description
# group: State group (backlog, unstarted, started, completed, cancelled)
# sequence: Display sequence order
# is_triage: Whether this is a triage state
# default: Whether this is the default state
# external_source: External source identifier (e.g., "jira")
# external_id: External system ID
# """
# # Auto-fill from context if not provided
# if workspace_slug is None and "workspace_slug" in context:
# workspace_slug = context["workspace_slug"]
# if project_id is None and "project_id" in context:
# project_id = context["project_id"]
#
# result = await method_executor.execute(
# "states",
# "create",
# name=name,
# color=color,
# project_id=project_id,
# workspace_slug=workspace_slug,
# description=description,
# group=group,
# sequence=sequence,
# is_triage=is_triage,
# default=default,
# external_source=external_source,
# external_id=external_id,
# )
#
# if result["success"]:
# return await PlaneToolBase.format_success_payload_with_url(f"Successfully created state '{name}'", result["data"], "state", context)
# else:
# return PlaneToolBase.format_error_payload("Failed to create state", result["error"])
#
# @tool
# async def states_list(
# project_id: Optional[str] = None,
# workspace_slug: Optional[str] = None,
# ) -> Dict[str, Any]:
# """List all workflow states in a project."""
# # Auto-fill from context if not provided
# if workspace_slug is None and "workspace_slug" in context:
# workspace_slug = context["workspace_slug"]
# if project_id is None and "project_id" in context:
# project_id = context["project_id"]
#
# result = await method_executor.execute(
# "states",
# "list",
# project_id=project_id,
# workspace_slug=workspace_slug,
# )
#
# if result["success"]:
# return PlaneToolBase.format_success_payload("Successfully retrieved states list", result["data"])
# else:
# return PlaneToolBase.format_error_payload("Failed to list states", result["error"])
#
# @tool
# async def states_retrieve(
# state_id: str,
# project_id: Optional[str] = None,
# workspace_slug: Optional[str] = None,
# ) -> Dict[str, Any]:
# """Retrieve details of a specific workflow state.
#
# Args:
# state_id: State ID (required)
# project_id: Project ID (required - provide from conversation context or previous actions)
# workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
# """
# # Auto-fill from context if not provided
# if workspace_slug is None and "workspace_slug" in context:
# workspace_slug = context["workspace_slug"]
# if project_id is None and "project_id" in context:
# project_id = context["project_id"]
#
# result = await method_executor.execute(
# "states",
# "retrieve",
# state_id=state_id,
# project_id=project_id,
# workspace_slug=workspace_slug,
# )
#
# if result["success"]:
# return PlaneToolBase.format_success_payload("Successfully retrieved state details", result["data"])
# else:
# return PlaneToolBase.format_error_payload("Failed to retrieve state", result["error"])
#
# @tool
# async def states_update(
# state_id: str,
# project_id: Optional[str] = None,
# workspace_slug: Optional[str] = None,
# name: Optional[str] = None,
# color: Optional[str] = None,
# description: Optional[str] = None,
# sequence: Optional[int] = None,
# group: Optional[str] = None,
# is_triage: Optional[bool] = None,
# default: Optional[bool] = None,
# external_source: Optional[str] = None,
# external_id: Optional[str] = None,
# ) -> Dict[str, Any]:
# """Update an existing workflow state.
#
# Args:
# state_id: State ID (required)
# name: New state name
# color: New state color
# description: New state description
# sequence: Display sequence order
# group: State group (backlog, unstarted, started, completed, cancelled)
# is_triage: Whether this is a triage state
# default: Whether this is the default state
# external_source: External source identifier (e.g., "jira")
# external_id: External system ID
# project_id: Project ID (required - provide from conversation context or previous actions)
# workspace_slug: Workspace slug (provide if known, otherwise auto-detected)
# """
# # Auto-fill from context if not provided
# if workspace_slug is None and "workspace_slug" in context:
# workspace_slug = context["workspace_slug"]
# if project_id is None and "project_id" in context:
# project_id = context["project_id"]
#
# # Build update data with only non-None values
# update_data = {
# k: v
# for k, v in {
# "name": name,
# "color": color,
# "description": description,
# "sequence": sequence,
# "group": group,
# "is_triage": is_triage,
# "default": default,
# "external_source": external_source,
# "external_id": external_id,
# }.items()
# if v is not None
# }
#
# result = await method_executor.execute(
# "states",
# "update",
# state_id=state_id,
# project_id=project_id,
# workspace_slug=workspace_slug,
# **update_data,
# )
#
# if result["success"]:
# return await PlaneToolBase.format_success_payload_with_url(f"Successfully updated state '{state_id}'", result["data"], "state", context)
# else:
# return PlaneToolBase.format_error_payload("Failed to update state", result["error"])
#
# return [states_create, states_list, states_retrieve, states_update]
@@ -0,0 +1,242 @@
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
#
# Licensed under the Plane Commercial License (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://plane.so/legals/eula
#
# DO NOT remove or modify this notice.
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
"""
Stickies API tools for Plane quick notes operations.
"""
from typing import Any
from typing import Dict
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
# ============================================================================
# STICKIES-SPECIFIC HANDLER FUNCTIONS
# ============================================================================
async def _stickies_pre_handler(
metadata: ToolMetadata,
kwargs: Dict[str, Any],
context: Dict[str, Any],
category: str,
method_key: str,
method_executor: Any = None,
) -> Dict[str, Any]:
"""Pre-processing handler for stickies tools.
Handles:
- Auto-generate description_html from description if not provided
"""
# If description is provided but description_html is not, auto-generate HTML version
# The UI displays description_html, so we need to ensure it's populated
if method_key in ["create", "update"]:
if "description" in kwargs and kwargs["description"] is not None:
if "description_html" not in kwargs or kwargs.get("description_html") is None:
# Convert plain text to simple HTML paragraph
# Escape HTML special characters and preserve line breaks
import html
escaped_description = html.escape(kwargs["description"])
# Replace newlines with <br> tags
escaped_description = escaped_description.replace("\n", "<br>")
kwargs["description_html"] = f"<p>{escaped_description}</p>"
return kwargs
# ============================================================================
# STICKIES TOOL DEFINITIONS
# ============================================================================
STICKY_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"create": ToolMetadata(
name="stickies_create",
description="Create a new sticky note for quick annotations. The main content/text should go in 'description' parameter.",
sdk_method="create_sticky",
returns_entity_type="sticky",
pre_handler=_stickies_pre_handler,
parameters=[
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="name",
type="Optional[str]",
required=False,
description="Optional title/heading for the sticky note",
),
ToolParameter(
name="description",
type="Optional[str]",
required=False,
description="Main content/text of the sticky note (use this for user's content)",
),
ToolParameter(
name="description_html",
type="Optional[str]",
required=False,
description="HTML formatted content (optional, auto-generated from description if not provided)",
),
ToolParameter(
name="color",
type="Optional[str]",
required=False,
description="Text color hex code (optional)",
),
ToolParameter(
name="background_color",
type="Optional[str]",
required=False,
description="Background color hex code (optional)",
),
ToolParameter(
name="logo_props",
type="Optional[dict]",
required=False,
description="Logo configuration JSON (optional)",
),
],
),
"list": ToolMetadata(
name="stickies_list",
description="List all sticky notes in the workspace",
sdk_method="list_stickies",
parameters=[
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"retrieve": ToolMetadata(
name="stickies_retrieve",
description="Retrieve a single sticky note by ID",
sdk_method="retrieve_sticky",
parameters=[
ToolParameter(
name="sticky_id",
type="str",
required=True,
description="Sticky ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"update": ToolMetadata(
name="stickies_update",
description="Update sticky note details",
sdk_method="update_sticky",
returns_entity_type="sticky",
pre_handler=_stickies_pre_handler,
parameters=[
ToolParameter(
name="sticky_id",
type="str",
required=True,
description="Sticky ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="name",
type="Optional[str]",
required=False,
description="New sticky note title",
),
ToolParameter(
name="description",
type="Optional[str]",
required=False,
description="New plain text description",
),
ToolParameter(
name="description_html",
type="Optional[str]",
required=False,
description="New HTML description",
),
ToolParameter(
name="color",
type="Optional[str]",
required=False,
description="New text color hex code",
),
ToolParameter(
name="background_color",
type="Optional[str]",
required=False,
description="New background color hex code",
),
ToolParameter(
name="logo_props",
type="Optional[dict]",
required=False,
description="New logo configuration JSON",
),
],
),
"delete": ToolMetadata(
name="stickies_delete",
description="Delete a sticky note",
sdk_method="delete_sticky",
parameters=[
ToolParameter(
name="sticky_id",
type="str",
required=True,
description="Sticky ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
}
# ============================================================================
# TOOL FACTORY
# ============================================================================
def get_sticky_tools(method_executor, context):
"""Return LangChain tools for the stickies category using auto-generation from metadata."""
return generate_tools_for_category(
category="stickies",
method_executor=method_executor,
context=context,
tool_definitions=STICKY_TOOL_DEFINITIONS,
)
@@ -0,0 +1,324 @@
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
#
# Licensed under the Plane Commercial License (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://plane.so/legals/eula
#
# DO NOT remove or modify this notice.
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
"""
Teamspaces API tools for Plane team collaboration operations.
"""
from typing import Dict
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
# ============================================================================
# TEAMSPACES TOOL DEFINITIONS
# ============================================================================
TEAMSPACE_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"create": ToolMetadata(
name="teamspaces_create",
description="Create a new teamspace for team collaboration",
sdk_method="create_teamspace",
returns_entity_type="teamspace",
parameters=[
ToolParameter(
name="name",
type="str",
required=True,
description="Teamspace name (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="description_html",
type="Optional[str]",
required=False,
description="HTML description of the teamspace",
),
ToolParameter(
name="logo_props",
type="Optional[dict]",
required=False,
description="Logo configuration JSON",
),
ToolParameter(
name="lead",
type="str",
required=False,
description="Team lead user ID (optional)",
),
],
),
"list": ToolMetadata(
name="teamspaces_list",
description="List teamspaces in the workspace",
sdk_method="list_teamspaces",
parameters=[
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"retrieve": ToolMetadata(
name="teamspaces_retrieve",
description="Retrieve a single teamspace by ID",
sdk_method="retrieve_teamspace",
parameters=[
ToolParameter(
name="teamspace_id",
type="str",
required=True,
description="Teamspace ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"update": ToolMetadata(
name="teamspaces_update",
description="Update teamspace details",
sdk_method="update_teamspace",
returns_entity_type="teamspace",
parameters=[
ToolParameter(
name="teamspace_id",
type="str",
required=True,
description="Teamspace ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="name",
type="Optional[str]",
required=False,
description="New teamspace name",
),
ToolParameter(
name="description_html",
type="Optional[str]",
required=False,
description="New HTML description",
),
ToolParameter(
name="logo_props",
type="Optional[dict]",
required=False,
description="New logo configuration JSON",
),
ToolParameter(
name="lead",
type="str",
required=False,
description="Team lead user ID (use empty string to remove the lead)",
),
],
),
"delete": ToolMetadata(
name="teamspaces_delete",
description="Delete a teamspace",
sdk_method="delete_teamspace",
parameters=[
ToolParameter(
name="teamspace_id",
type="str",
required=True,
description="Teamspace ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"add_members": ToolMetadata(
name="teamspaces_add_members",
description="Add members to a teamspace",
sdk_method="add_teamspace_members",
parameters=[
ToolParameter(
name="teamspace_id",
type="str",
required=True,
description="Teamspace ID (required)",
),
ToolParameter(
name="member_ids",
type="List[str]",
required=True,
description="List of member (user) IDs to add (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"list_members": ToolMetadata(
name="teamspaces_list_members",
description="List members in a teamspace",
sdk_method="list_teamspace_members",
parameters=[
ToolParameter(
name="teamspace_id",
type="str",
required=True,
description="Teamspace ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"remove_members": ToolMetadata(
name="teamspaces_remove_members",
description="Remove members from a teamspace",
sdk_method="remove_teamspace_members",
parameters=[
ToolParameter(
name="teamspace_id",
type="str",
required=True,
description="Teamspace ID (required)",
),
ToolParameter(
name="member_ids",
type="List[str]",
required=True,
description="List of member (user) IDs to remove (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"add_projects": ToolMetadata(
name="teamspaces_add_projects",
description="Add projects to a teamspace",
sdk_method="add_teamspace_projects",
parameters=[
ToolParameter(
name="teamspace_id",
type="str",
required=True,
description="Teamspace ID (required)",
),
ToolParameter(
name="project_ids",
type="List[str]",
required=True,
description="List of project IDs to add (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"list_projects": ToolMetadata(
name="teamspaces_list_projects",
description="List projects in a teamspace",
sdk_method="list_teamspace_projects",
parameters=[
ToolParameter(
name="teamspace_id",
type="str",
required=True,
description="Teamspace ID (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"remove_projects": ToolMetadata(
name="teamspaces_remove_projects",
description="Remove projects from a teamspace",
sdk_method="remove_teamspace_projects",
parameters=[
ToolParameter(
name="teamspace_id",
type="str",
required=True,
description="Teamspace ID (required)",
),
ToolParameter(
name="project_ids",
type="List[str]",
required=True,
description="List of project IDs to remove (required)",
),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
}
# ============================================================================
# TOOL FACTORY
# ============================================================================
def get_teamspace_tools(method_executor, context):
"""Return LangChain tools for the teamspaces category using auto-generation from metadata."""
return generate_tools_for_category(
category="teamspaces",
method_executor=method_executor,
context=context,
tool_definitions=TEAMSPACE_TOOL_DEFINITIONS,
)
+148 -187
View File
@@ -13,197 +13,158 @@
Types API tools for Plane issue type management.
"""
from typing import Any
from typing import Dict
from typing import Optional
from langchain_core.tools import tool
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
from .base import PlaneToolBase
# ============================================================================
# TYPES TOOL DEFINITIONS
# ============================================================================
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing type actions
TYPE_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"create": ToolMetadata(
name="types_create",
description="Create a new work item type",
sdk_method="create_issue_type",
parameters=[
ToolParameter(name="name", type="str", required=True, description="Name of the work item type (required)"),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(name="description", type="Optional[str]", required=False, description="Optional description of the type"),
ToolParameter(
name="project_ids", type="Optional[List[str]]", required=False, description="List of project IDs to associate with this type"
),
ToolParameter(name="is_epic", type="Optional[bool]", required=False, description="Flag to mark this type as an epic type"),
ToolParameter(name="is_active", type="Optional[bool]", required=False, description="Activation status of the type"),
ToolParameter(
name="external_source", type="Optional[str]", required=False, description="External integration source (e.g., 'jira', 'github')"
),
ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system identifier for this type"),
],
),
"list": ToolMetadata(
name="types_list",
description="List all work item types",
sdk_method="list_issue_types",
parameters=[
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"retrieve": ToolMetadata(
name="types_retrieve",
description="Get a single type by ID",
sdk_method="retrieve_issue_type",
parameters=[
ToolParameter(name="type_id", type="str", required=True, description="Type ID (required)"),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"update": ToolMetadata(
name="types_update",
description="Update work item type details",
sdk_method="update_issue_type",
parameters=[
ToolParameter(name="type_id", type="str", required=True, description="UUID of the work item type to update (required)"),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(name="name", type="Optional[str]", required=False, description="Updated name of the type"),
ToolParameter(name="description", type="Optional[str]", required=False, description="Updated description"),
ToolParameter(
name="project_ids", type="Optional[list[str]]", required=False, description="Updated list of project IDs to associate with this type"
),
ToolParameter(name="is_epic", type="Optional[bool]", required=False, description="Updated epic type flag"),
ToolParameter(name="is_active", type="Optional[bool]", required=False, description="Updated activation status"),
ToolParameter(name="external_source", type="Optional[str]", required=False, description="Updated external integration source"),
ToolParameter(name="external_id", type="Optional[str]", required=False, description="Updated external system identifier"),
],
),
"delete": ToolMetadata(
name="types_delete",
description="Delete a type",
sdk_method="delete_issue_type",
parameters=[
ToolParameter(name="type_id", type="str", required=True, description="Type ID (required)"),
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="project_id",
type="Optional[str]",
required=False,
description="Project ID (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
}
# ============================================================================
# TOOL FACTORY
# ============================================================================
def get_type_tools(method_executor, context):
"""Return LangChain tools for the types category using method_executor and context."""
"""Get all Types API tools."""
@tool
async def types_create(
name: str,
description: Optional[str] = None,
project_ids: Optional[list[str]] = None,
is_epic: Optional[bool] = None,
is_active: Optional[bool] = None,
external_source: Optional[str] = None,
external_id: Optional[str] = None,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a new work item type.
Args:
name: Name of the work item type (required)
description: Optional description of the type
project_ids: List of project IDs to associate with this type
is_epic: Flag to mark this type as an epic type
is_active: Activation status of the type
external_source: External integration source (e.g., 'jira', 'github')
external_id: External system identifier for this type
project_id: UUID of the project (auto-filled from context)
workspace_slug: Workspace slug identifier (auto-filled from context)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"types",
"create",
name=name,
description=description,
project_ids=project_ids,
is_epic=is_epic,
is_active=is_active,
external_source=external_source,
external_id=external_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload(f"Successfully created type '{name}'", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to create type", result["error"])
@tool
async def types_list(project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""List all work item types."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute("types", "list", project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved types list", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list types", result["error"])
@tool
async def types_retrieve(
type_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Get a single type by ID."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"types",
"retrieve",
type_id=type_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved type", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to retrieve type", result["error"])
@tool
async def types_update(
type_id: str,
name: Optional[str] = None,
description: Optional[str] = None,
project_ids: Optional[list[str]] = None,
is_epic: Optional[bool] = None,
is_active: Optional[bool] = None,
external_source: Optional[str] = None,
external_id: Optional[str] = None,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Update work item type details.
Args:
type_id: UUID of the work item type to update (required)
name: Updated name of the type
description: Updated description
project_ids: Updated list of project IDs to associate with this type
is_epic: Updated epic type flag
is_active: Updated activation status
external_source: Updated external integration source
external_id: Updated external system identifier
project_id: UUID of the project (auto-filled from context)
workspace_slug: Workspace slug identifier (auto-filled from context)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Build update data
update_data: dict[str, Any] = {}
if name is not None:
update_data["name"] = name
if description is not None:
update_data["description"] = description
if project_ids is not None:
update_data["project_ids"] = project_ids
if is_epic is not None:
update_data["is_epic"] = is_epic
if is_active is not None:
update_data["is_active"] = is_active
if external_source is not None:
update_data["external_source"] = external_source
if external_id is not None:
update_data["external_id"] = external_id
result = await method_executor.execute(
"types",
"update",
type_id=type_id,
project_id=project_id,
workspace_slug=workspace_slug,
**update_data,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully updated type", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to update type", result["error"])
@tool
async def types_delete(
type_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Delete a type."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"types",
"delete",
type_id=type_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully deleted type", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to delete type", result["error"])
return [types_create, types_list, types_retrieve, types_update, types_delete]
"""Return LangChain tools for the types category using auto-generation from metadata."""
return generate_tools_for_category(
category="types",
method_executor=method_executor,
context=context,
tool_definitions=TYPE_TOOL_DEFINITIONS,
)
+46 -16
View File
@@ -11,29 +11,59 @@
"""
Users API tools for Plane user management operations.
MIGRATED TO AUTO-GENERATED TOOLS
Tool metadata defined in this file (USER_TOOL_DEFINITIONS) for modularity.
Old manual definitions kept below for comparison/rollback safety.
"""
from typing import Any
from typing import Dict
from langchain_core.tools import tool
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from .base import PlaneToolBase
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing user actions
# Tool metadata for users category
USER_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"get_current_user": ToolMetadata(
name="users_get_current",
description="Get current user information",
sdk_method="get_current_user",
parameters=[],
),
}
def get_user_tools(method_executor, context):
"""Return LangChain tools for the users category using method_executor and context."""
"""Return LangChain tools for the users category using auto-generation from metadata."""
@tool
async def users_get_current() -> Dict[str, Any]:
"""Get current user information."""
result = await method_executor.execute("users", "get_current")
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved current user", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to get current user", result["error"])
user_tools = generate_tools_for_category(
category="users",
method_executor=method_executor,
context=context,
tool_definitions=USER_TOOL_DEFINITIONS,
)
return [users_get_current]
return user_tools
# ============================================================================
# OLD MANUAL TOOL DEFINITIONS (COMMENTED OUT - KEPT FOR COMPARISON)
# To rollback: uncomment below and comment out the auto-generation code above
# ============================================================================
# from langchain_core.tools import tool
# from .base import PlaneToolBase
#
# def get_user_tools(method_executor, context):
# """Return LangChain tools for the users category using method_executor and context."""
#
# @tool
# async def users_get_current() -> Dict[str, Any]:
# """Get current user information."""
# result = await method_executor.execute("users", "get_current")
# if result["success"]:
# return PlaneToolBase.format_success_payload("Successfully retrieved current user", result["data"])
# else:
# return PlaneToolBase.format_error_payload("Failed to get current user", result["error"])
#
# return [users_get_current]
File diff suppressed because it is too large Load Diff
+143 -231
View File
@@ -13,244 +13,156 @@
Worklogs API tools for Plane time tracking operations.
"""
import contextlib
import re
import uuid
from typing import Any
from typing import Dict
from typing import Optional
from langchain_core.tools import tool
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
from .base import PlaneToolBase
# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py
# Returns LangChain tools implementing worklog actions
# Pre-handler combining project resolution AND duration parsing
async def _worklog_pre_handler(
metadata: ToolMetadata,
kwargs: Dict[str, Any],
context: Dict[str, Any],
category: str,
method_key: str,
method_executor: Any,
) -> Dict[str, Any]:
"""Resolve project_id and parse duration string to minutes."""
# 1. Project ID Resolution
issue_id = kwargs.get("issue_id")
project_id = kwargs.get("project_id")
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
kwargs["project_id"] = issue_info["project_id"]
except Exception:
pass
# 2. Duration Parsing (e.g., "1h 30m" -> 90)
duration = kwargs.get("duration")
if duration is not None:
if isinstance(duration, str):
# Try to parse string format "Xh Ym" or just "Xm"
total_minutes = 0
# Simple regex for parsing
# Matches "1h", "1.5h", "30m", "1h 30m"
hours_match = re.search(r"(\d+(?:\.\d+)?)\s*h", duration.lower())
minutes_match = re.search(r"(\d+(?:\.\d+)?)\s*m", duration.lower())
if hours_match:
with contextlib.suppress(ValueError):
total_minutes += int(float(hours_match.group(1)) * 60)
if minutes_match:
with contextlib.suppress(ValueError):
total_minutes += int(float(minutes_match.group(1)))
# If regex matched nothing but it's a digit string, treat as minutes
if not hours_match and not minutes_match:
if duration.isdigit():
total_minutes = int(duration)
# If we found something, update kwargs
if total_minutes > 0:
kwargs["duration"] = total_minutes
# If int/float passed directly, ensure int
elif isinstance(duration, (int, float)):
kwargs["duration"] = int(duration)
return kwargs
WORKLOGS_TOOL_DEFINITIONS = {
"create": ToolMetadata(
name="worklogs_create",
description="Create a new worklog (time entry).",
sdk_method="create_issue_worklog",
parameters=[
ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"),
ToolParameter(name="description", description="Worklog description/note", required=True, type="str"),
ToolParameter(
name="duration", description="Duration (e.g., '1h 30m' or minutes as int)", required=True, type="Any"
), # Accepting Any to allow string
ToolParameter(
name="project_id", description="Project ID (optional, auto-filled)", required=False, type="Optional[str]", auto_fill_from_context=True
),
ToolParameter(
name="workspace_slug",
description="Workspace slug (optional, auto-filled)",
required=False,
type="Optional[str]",
auto_fill_from_context=True,
),
],
pre_handler=_worklog_pre_handler,
returns_entity_type="worklog",
),
"list": ToolMetadata(
name="worklogs_list",
description="List worklogs for an issue.",
sdk_method="list_issue_worklogs",
parameters=[
ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"),
ToolParameter(name="project_id", description="Project ID", required=False, type="Optional[str]", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True),
],
pre_handler=_worklog_pre_handler,
),
"update": ToolMetadata(
name="worklogs_update",
description="Update time entry.",
sdk_method="update_issue_worklog",
parameters=[
ToolParameter(name="worklog_id", description="Worklog ID", required=True, type="str"),
ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"),
ToolParameter(name="description", description="Worklog description", required=False, type="Optional[str]"),
ToolParameter(name="duration", description="Duration (e.g., '1h 30m')", required=False, type="Any"),
ToolParameter(name="project_id", description="Project ID", required=False, type="Optional[str]", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True),
ToolParameter(name="created_by", description="User ID who created", required=False, type="Optional[str]"),
ToolParameter(name="updated_by", description="User ID who updated", required=False, type="Optional[str]"),
],
pre_handler=_worklog_pre_handler,
returns_entity_type="worklog",
),
"delete": ToolMetadata(
name="worklogs_delete",
description="Delete time entry.",
sdk_method="delete_issue_worklog",
parameters=[
ToolParameter(name="worklog_id", description="Worklog ID", required=True, type="str"),
ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"),
ToolParameter(name="project_id", description="Project ID", required=False, type="Optional[str]", auto_fill_from_context=True),
ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True),
],
pre_handler=_worklog_pre_handler,
returns_entity_type="worklog",
),
}
def get_worklog_tools(method_executor, context):
"""Return LangChain tools for the worklogs category using method_executor and context."""
@tool
async def worklogs_create(
issue_id: str,
description: str,
duration: int,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a new worklog entry.
Args:
issue_id: Issue ID (required)
description: Worklog description (required)
duration: Duration in minutes (required)
project_id: Project ID (optional, auto-filled from context or resolved from issue_id)
workspace_slug: Workspace slug (optional, auto-filled from context)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Programmatically resolve project_id from issue_id if missing or invalid (non-UUID)
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
project_id = issue_info["project_id"]
except Exception:
pass
result = await method_executor.execute(
"worklogs",
"create",
issue_id=issue_id,
description=description,
duration=duration,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully created worklog", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to create worklog", result["error"])
@tool
async def worklogs_list(
issue_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""List worklogs for an issue."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Programmatically resolve project_id from issue_id if missing or invalid (non-UUID)
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
project_id = issue_info["project_id"]
except Exception:
pass
result = await method_executor.execute(
"worklogs",
"list",
issue_id=issue_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved worklogs", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to list worklogs", result["error"])
@tool
async def worklogs_get_summary(
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Get project worklog summary."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"worklogs",
"get_summary",
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully retrieved worklog summary", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to get worklog summary", result["error"])
@tool
async def worklogs_update(
worklog_id: str,
issue_id: str,
description: Optional[str] = None,
duration: Optional[int] = None,
created_by: Optional[str] = None,
updated_by: Optional[str] = None,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Update time entry.
Args:
worklog_id: Worklog ID (required)
issue_id: Issue ID (required)
description: Worklog description
duration: Duration in minutes
created_by: User ID who created the worklog
updated_by: User ID who updated the worklog
project_id: Project ID (optional, auto-filled from context or resolved from issue_id)
workspace_slug: Workspace slug (optional, auto-filled from context)
"""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Programmatically resolve project_id from issue_id if missing or invalid (non-UUID)
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
project_id = issue_info["project_id"]
except Exception:
pass
# Build update data
update_data: Dict[str, Any] = {}
if description is not None:
update_data["description"] = description
if duration is not None:
update_data["duration"] = duration
if created_by is not None:
update_data["created_by"] = created_by
if updated_by is not None:
update_data["updated_by"] = updated_by
result = await method_executor.execute(
"worklogs",
"update",
worklog_id=worklog_id,
issue_id=issue_id,
project_id=project_id,
workspace_slug=workspace_slug,
**update_data,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully updated worklog", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to update worklog", result["error"])
@tool
async def worklogs_delete(
worklog_id: str,
project_id: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> Dict[str, Any]:
"""Delete time entry."""
# Auto-fill from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
if project_id is None and "project_id" in context:
project_id = context["project_id"]
result = await method_executor.execute(
"worklogs",
"delete",
worklog_id=worklog_id,
project_id=project_id,
workspace_slug=workspace_slug,
)
if result["success"]:
return PlaneToolBase.format_success_payload("Successfully deleted worklog", result["data"])
else:
return PlaneToolBase.format_error_payload("Failed to delete worklog", result["error"])
return [worklogs_create, worklogs_list, worklogs_get_summary, worklogs_update, worklogs_delete]
return generate_tools_for_category("worklogs", method_executor, context, WORKLOGS_TOOL_DEFINITIONS)
@@ -0,0 +1,207 @@
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
#
# Licensed under the Plane Commercial License (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://plane.so/legals/eula
#
# DO NOT remove or modify this notice.
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
"""
Workspaces API tools for Plane workspace features management.
"""
from typing import Any
from typing import Dict
from pi import logger
from pi.services.actions.tool_generator import generate_tools_for_category
from pi.services.actions.tool_metadata import ToolMetadata
from pi.services.actions.tool_metadata import ToolParameter
log = logger.getChild("workspaces")
# All workspace feature fields - SDK requires all fields to be present
WORKSPACE_FEATURE_FIELDS = ["project_grouping", "initiatives", "teams", "customers", "wiki", "pi"]
async def _workspaces_pre_handler(
metadata: ToolMetadata,
kwargs: Dict[str, Any],
context: Dict[str, Any],
category: str,
method_key: str,
method_executor: Any = None,
) -> Dict[str, Any]:
"""Pre-processing handler for workspace tools.
Handles:
- update_features: Fetch current features and merge with update (SDK requires all fields)
"""
if method_key == "update_features":
workspace_slug = kwargs.get("workspace_slug")
if not workspace_slug:
log.warning("workspace_slug missing for update_features, cannot fetch current features")
return kwargs
if not method_executor:
log.warning("method_executor not available, cannot fetch current features")
return kwargs
# Fetch current features using the existing method_executor
try:
result = await method_executor.execute("workspaces", "get_features", workspace_slug=workspace_slug)
if result.get("success") and result.get("data"):
features_data = result["data"]
# Merge: current values as defaults, update with provided values
for field in WORKSPACE_FEATURE_FIELDS:
if field not in kwargs or kwargs[field] is None:
kwargs[field] = features_data.get(field, False)
log.debug(f"Merged workspace features: {kwargs}")
else:
log.warning(f"Could not fetch current workspace features: {result}")
except Exception as e:
log.error(f"Failed to fetch current workspace features: {e}")
# Don't fail - let SDK validation fail with clear error
return kwargs
async def _workspaces_post_handler(
metadata: ToolMetadata,
result: Dict[str, Any],
kwargs: Dict[str, Any],
context: Dict[str, Any],
method_executor: Any,
category: str,
method_key: str,
) -> Dict[str, Any]:
"""Post-processing handler for workspace tools.
Handles:
- Inject workspace metadata into response for entity URL generation
"""
# For workspace operations, inject workspace info into the result for URL construction
workspace_slug = kwargs.get("workspace_slug")
if workspace_slug and result.get("success"):
# Get workspace_id from context - try multiple keys
workspace_id = context.get("workspace_id") or context.get("workspace")
# Ensure the result data has the workspace metadata for URL generation
if "data" in result:
if isinstance(result["data"], dict):
# Inject workspace identifiers if missing
if "id" not in result["data"] and workspace_id:
result["data"]["id"] = str(workspace_id)
if "slug" not in result["data"]:
result["data"]["slug"] = workspace_slug
if "name" not in result["data"]:
# Use slug as a fallback name
result["data"]["name"] = workspace_slug
else:
# Create a minimal data structure for URL generation
result["data"] = {
"id": str(workspace_id) if workspace_id else None,
"slug": workspace_slug,
"name": workspace_slug,
}
return result
# ============================================================================
# WORKSPACES TOOL DEFINITIONS
# ============================================================================
WORKSPACE_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = {
"get_features": ToolMetadata(
name="workspaces_get_features",
description="Get enabled workspace features (initiatives, teams, customers, etc.)",
sdk_method="get_workspace_features",
returns_entity_type="workspace",
post_handler=_workspaces_post_handler,
parameters=[
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
],
),
"update_features": ToolMetadata(
name="workspaces_update_features",
description="Enable or disable workspace features",
sdk_method="update_workspace_features",
returns_entity_type="workspace",
pre_handler=_workspaces_pre_handler,
post_handler=_workspaces_post_handler,
parameters=[
ToolParameter(
name="workspace_slug",
type="Optional[str]",
required=False,
description="Workspace slug (auto-detected from context)",
auto_fill_from_context=True,
),
ToolParameter(
name="project_grouping",
type="Optional[bool]",
required=False,
description="Enable/disable project grouping feature (optional)",
),
ToolParameter(
name="initiatives",
type="Optional[bool]",
required=False,
description="Enable/disable initiatives feature (optional)",
),
ToolParameter(
name="teams",
type="Optional[bool]",
required=False,
description="Enable/disable teams feature (optional)",
),
ToolParameter(
name="customers",
type="Optional[bool]",
required=False,
description="Enable/disable customers feature (optional)",
),
ToolParameter(
name="wiki",
type="Optional[bool]",
required=False,
description="Enable/disable wiki feature (optional)",
),
ToolParameter(
name="pi",
type="Optional[bool]",
required=False,
description="Enable/disable PI (Plane Intelligence) feature (optional)",
),
],
),
}
# ============================================================================
# TOOL FACTORY
# ============================================================================
def get_workspace_tools(method_executor, context):
"""Return LangChain tools for the workspaces category using auto-generation from metadata."""
return generate_tools_for_category(
category="workspaces",
method_executor=method_executor,
context=context,
tool_definitions=WORKSPACE_TOOL_DEFINITIONS,
)
+28 -5
View File
@@ -35,6 +35,13 @@ from pi.services.chat.helpers.placeholder_orchestrator import PlaceholderOrchest
log = logger.getChild(__name__)
# Mapping of entity_type to tool category
# Some tools have different entity_type than their category (e.g., epic tools are in workitems)
ENTITY_TYPE_TO_CATEGORY = {
"epic": "workitems", # Epic tools are in workitems category
# Add more mappings as needed
}
async def get_execution_context(request, chatbot: PlaneChatBot, user_id, db) -> Tuple[str, Optional[str], bool]:
"""Extract execution context from request and database."""
@@ -79,7 +86,7 @@ class BuildModeToolExecutor:
if not workspace_slug:
workspace_slug = await get_workspace_slug(str(workspace_id))
token, project_id, _ = await get_execution_context(request, self.chatbot, user_id, self.db)
token, project_id, is_project_chat = await get_execution_context(request, self.chatbot, user_id, self.db)
# Validate token exists
if not token:
@@ -101,7 +108,14 @@ class BuildModeToolExecutor:
method_executor = MethodExecutor(actions_executor)
context = {"workspace_slug": workspace_slug, "project_id": project_id, "message_id": message_id, "chat_id": chat_id}
context = {
"workspace_slug": workspace_slug,
"workspace_id": str(workspace_id) if workspace_id else None,
"project_id": project_id,
"message_id": message_id,
"chat_id": chat_id,
"is_project_chat": is_project_chat,
}
# Execute single action
# If single action, execute directly
@@ -134,16 +148,25 @@ class BuildModeToolExecutor:
args = planned_action["args"]
entity_type = planned_action["entity_type"]
# Map entity_type to actual category (e.g., "epic" -> "workitems")
category = ENTITY_TYPE_TO_CATEGORY.get(entity_type, entity_type)
# Build tool
all_category_tools = self.chatbot._build_method_tools(entity_type, method_executor, context)
all_category_tools = self.chatbot._build_method_tools(category, method_executor, context)
# get the specific tool we need
tool = next((t for t in all_category_tools if t.name == tool_name), None)
if not tool:
raise ValueError(f"Tool '{tool_name}' not found in category '{entity_type}'")
raise ValueError(f"Tool '{tool_name}' not found in category '{category}' (entity_type: '{entity_type}')")
# Execute the tool - EXPECT structured payload dict
result = await tool.ainvoke(args)
log.info(f"[DEBUG] execute_single_action: About to invoke tool '{tool_name}' with args: {args}")
try:
result = await tool.ainvoke(args)
log.info(f"[DEBUG] execute_single_action: Tool '{tool_name}' returned successfully. Result: {result}")
except Exception as tool_error:
log.error(f"[DEBUG] execute_single_action: Tool '{tool_name}' raised exception: {tool_error}", exc_info=True)
raise
if not isinstance(result, dict):
raise ValueError(f"Tool '{tool_name}' must return a structured payload dict, got {type(result)}")
+26 -1
View File
@@ -13,6 +13,7 @@
import contextlib
import json
import re
from collections.abc import AsyncIterator
from typing import Any
from typing import Dict
@@ -91,7 +92,7 @@ async def _inject_urls_into_entities(entities: list[Dict[str, Any]] | str, pendi
# This happens when action tools (not retrieval tools) are used
if not url_map:
from pi import settings
from pi.agents.sql_agent.tools import construct_entity_urls_from_db
from pi.agents.sql_agent.helpers import construct_entity_urls_from_db
# Collect entity IDs by type
entity_ids_by_type: Dict[str, List[str]] = {"issues": [], "pages": [], "cycles": [], "modules": [], "projects": []}
@@ -446,6 +447,21 @@ async def execute_tools_for_build_mode(
_has_tool_calls = hasattr(response, "tool_calls") and bool(getattr(response, "tool_calls", None))
except Exception:
_has_tool_calls = False
# FAILSAFE: Detect if LLM mistakenly put tool_calls in content instead of using API
# This can happen with certain models/prompts - detect and handle gracefully
response_content = str(getattr(response, "content", "") or "").strip()
if not _has_tool_calls and "```tool_calls" in response_content:
log.warning(f"ChatID: {chat_id} - DETECTED: LLM output tool_calls as markdown instead of API. Stripping from content.")
# Strip the tool_calls markdown block from content to avoid showing JSON to user
# Remove ```tool_calls ... ``` blocks from content
cleaned_content = re.sub(r"```tool_calls\s*\n?\[[\s\S]*?\]\s*\n?```", "", response_content)
cleaned_content = cleaned_content.strip()
# Update response content to cleaned version for streaming
if hasattr(response, "content"):
response.content = cleaned_content
log.info(f"ChatID: {chat_id} - Stripped tool_calls markdown. Cleaned content: {cleaned_content[:200]}...")
log.info(f"ChatID: {chat_id} - Has Tool Calls: {_has_tool_calls}")
if _has_tool_calls:
@@ -872,6 +888,15 @@ async def execute_tools_for_build_mode(
# update _has_tool_calls
_has_tool_calls = has_more_tool_calls
# FAILSAFE: Detect if LLM mistakenly put tool_calls in content in iterations
if not _has_tool_calls:
iter_content = str(getattr(response, "content", "") or "").strip()
if "```tool_calls" in iter_content:
log.warning(f"ChatID: {chat_id} - DETECTED (iteration {iteration_count}): LLM output tool_calls as markdown. Stripping.")
cleaned_content = re.sub(r"```tool_calls\s*\n?\[[\s\S]*?\]\s*\n?```", "", iter_content).strip()
if hasattr(response, "content"):
response.content = cleaned_content
# Log planner decisions for this iteration
try:
if _has_tool_calls:
@@ -186,6 +186,12 @@ def extract_tool_params_from_artifact_data(artifact_data: Dict[str, Any], entity
"epic": {"cycle_id", "module_ids"},
}
# Fields that are auto-generated by SDK and should never be sent to any tool
SDK_AUTO_GENERATED_FIELDS = {"logo_props", "icon_prop", "emoji"}
# Fields that are project-specific and should not be sent to other entities
PROJECT_ONLY_FIELDS = {"cover_image"}
tool_params = {}
unsupported = CREATE_UNSUPPORTED_FIELDS.get(entity_type, set()) if action == "create" else set()
@@ -217,23 +223,29 @@ def extract_tool_params_from_artifact_data(artifact_data: Dict[str, Any], entity
if key in {"entity_info", "artifact_sub_type"}:
continue
# Map artifact field names to tool parameter names FIRST (needed for subsequent checks)
mapped_key = ARTIFACT_TO_TOOL_MAPPING.get(key, key)
# Skip SDK auto-generated fields (logo_props, icon_prop, emoji) for all entities
if key in SDK_AUTO_GENERATED_FIELDS or mapped_key in SDK_AUTO_GENERATED_FIELDS:
log.debug(f"Skipping SDK auto-generated field '{key}' for {entity_type}")
continue
# Skip project-only fields for non-project entities
if entity_type != "project" and (key in PROJECT_ONLY_FIELDS or mapped_key in PROJECT_ONLY_FIELDS):
log.debug(f"Skipping project-only field '{key}' for {entity_type}")
continue
# Filter unsupported fields for this entity type and action
if key in unsupported:
log.warning(f"Field '{key}' cannot be set during {entity_type} {action}, skipping")
continue
# Map artifact field names to tool parameter names
mapped_key = ARTIFACT_TO_TOOL_MAPPING.get(key, key)
if mapped_key == "project_lead" and isinstance(value, dict) and value.get("id"):
value = value["id"]
tool_params[mapped_key] = value
# Project logo/icon compatibility:
if mapped_key == "logo_props" and "icon_prop" not in tool_params:
tool_params["icon_prop"] = value
log.debug(f"Extracted tool params for {entity_type} {action}: {list(tool_params.keys())}")
return tool_params
@@ -271,22 +283,64 @@ async def load_artifacts(request_data: List[ArtifactData], db: AsyncSession) ->
# Transform edited artifact data from frontend schema to tool parameters
tool_args = extract_tool_params_from_artifact_data(req_item.action_data or {}, entity_type, action)
# For UPDATE actions, preserve the entity ID from the original artifact
# Get original tool args for preserving context fields
original_tool_args = artifact.data.get("tool_args_raw", {})
# PRESERVE CONTEXT FIELDS for all actions (these are never editable but required for execution)
# workspace_slug and project_id are context, not user-editable fields
if "workspace_slug" in original_tool_args and "workspace_slug" not in tool_args:
tool_args["workspace_slug"] = original_tool_args["workspace_slug"]
log.debug(f"Preserved workspace_slug={original_tool_args["workspace_slug"]} for {entity_type} {action}")
if "project_id" in original_tool_args and "project_id" not in tool_args:
tool_args["project_id"] = original_tool_args["project_id"]
log.debug(f"Preserved project_id={original_tool_args["project_id"]} for {entity_type} {action}")
# PRESERVE NON-EDITABLE PLANNING PARAMETERS
# These are planning-time decisions that aren't exposed in the edit UI but are critical for execution
NON_EDITABLE_PARAMS = {
# Project feature flags (planning-time decisions)
"cycle_view",
"module_view",
"page_view",
"intake_view",
"is_issue_type_enabled",
"is_time_tracking_enabled",
"issue_views_view",
"guest_view_all_features",
# Project update_features params (must be preserved from plan)
"epics",
"modules",
"cycles",
"views",
"pages",
"intakes",
"work_item_types",
# Other planning parameters
"timezone",
"archive_in",
"close_in",
"external_id",
"external_source",
# User/owner assignments that may be auto-set
"owned_by",
"project_lead",
"default_assignee",
}
for param in NON_EDITABLE_PARAMS:
if param in original_tool_args and param not in tool_args:
tool_args[param] = original_tool_args[param]
log.debug(f"Preserved non-editable param {param}={original_tool_args[param]} for {entity_type} {action}")
# For UPDATE actions, also preserve the entity ID being updated
# The frontend edits fields but doesn't include the entity ID being updated
if action == "update":
original_tool_args = artifact.data.get("tool_args_raw", {})
# Preserve the entity's ID field (e.g., issue_id, cycle_id, module_id)
id_field = ENTITY_ID_FIELD_MAP.get(entity_type)
if id_field and id_field in original_tool_args and id_field not in tool_args:
tool_args[id_field] = original_tool_args[id_field]
log.debug(f"Preserved {id_field}={original_tool_args[id_field]} for {entity_type} update")
# Also preserve project_id if it's not in the edited data
# (many update tools require it as context even though it's not being changed)
if "project_id" in original_tool_args and "project_id" not in tool_args:
tool_args["project_id"] = original_tool_args["project_id"]
log.info(f"Preserved project_id={original_tool_args["project_id"]} for {entity_type} update")
else:
# Use original planned tool arguments
tool_args = artifact.data.get("tool_args_raw", {})
@@ -296,6 +296,7 @@ def build_planning_tools(
"module",
"worklogs",
"worklog",
"workitems", # Added to support epic feature checking via projects_retrieve
"epics",
"epic",
"intake",
@@ -307,6 +308,15 @@ def build_planning_tools(
"pages",
"page",
]
workspace_scoped_cats = [
"initiatives",
"initiative",
"teamspaces",
"teamspace",
"customers",
"customer",
]
all_method_tools: List[Any] = []
built_categories: List[str] = []
for sel in selections_list:
@@ -331,6 +341,14 @@ def build_planning_tools(
tools_for_project = [t for t in tools_for_project if getattr(t, "name", "") in ["projects_retrieve", "projects_update"]]
all_method_tools.extend(tools_for_project)
if cat in workspace_scoped_cats:
tools_for_workspace = chatbot_instance._build_planning_method_tools("workspaces", method_executor, context)
# remove all tools except workspaces_get_features and workspaces_update_features
tools_for_workspace = [
t for t in tools_for_workspace if getattr(t, "name", "") in ["workspaces_get_features", "workspaces_update_features"]
]
all_method_tools.extend(tools_for_workspace)
except Exception as e:
log.warning(f"Failed to build tools for category {cat}: {e}")
@@ -22,19 +22,54 @@ async def infer_selected_entity(args: Dict[str, Any], context: Dict[str, Any], e
"""
Generic best-effort entity inference for execute-action results.
Some tools dont return an `entity` payload even on success. When we can,
Some tools don't return an `entity` payload even on success. When we can,
infer the selected entity from request args + context (e.g., issue_id).
"""
try:
workspace_slug = context.get("workspace_slug")
hint = (entity_type_hint or "").strip().lower()
# Fast-path inference for entities where URL format is fully deterministic from args/context.
# This is especially important for "relationship" tools (e.g. add work-items to cycle)
# where the API response does not include the cycle/module payload.
base = str(settings.plane_api.FRONTEND_URL or "").rstrip("/")
project_id = args.get("project_id") or context.get("project_id")
cycle_id = args.get("cycle_id")
if workspace_slug and project_id and cycle_id:
return {
"entity_url": f"{base}/{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/",
"entity_name": None,
"entity_type": "cycle" if hint in {"", "cycle"} else hint,
"entity_id": str(cycle_id),
}
module_id = args.get("module_id")
if workspace_slug and project_id and module_id:
return {
"entity_url": f"{base}/{workspace_slug}/projects/{project_id}/modules/{module_id}/",
"entity_name": None,
"entity_type": "module" if hint in {"", "module"} else hint,
"entity_id": str(module_id),
}
# Project URL: /{workspace}/projects/{project}/overview/
if workspace_slug and project_id and hint == "project":
return {
"entity_url": f"{base}/{workspace_slug}/projects/{project_id}/overview/",
"entity_name": None,
"entity_type": "project",
"entity_id": str(project_id),
}
# Work-item inference (needs identifier resolution for best UX)
issue_id = args.get("issue_id")
if workspace_slug and issue_id:
from pi.agents.sql_agent.tools import construct_action_entity_url
from pi.agents.sql_agent.helpers import construct_action_entity_url
url_info = await construct_action_entity_url({"id": str(issue_id)}, "workitem", str(workspace_slug), settings.plane_api.FRONTEND_URL)
if not url_info or not isinstance(url_info, dict):
return None
hint = (entity_type_hint or "").strip().lower()
subresource_id_fields = {
"comment": "comment_id",
@@ -188,7 +188,7 @@ class PlaceholderOrchestrator:
remaining.remove(action)
except Exception as e:
log.error(f"Failed to execute action {action.get("tool_name")}: {e}")
log.error(f"Failed to execute action {action.get("tool_name")}: {e}", exc_info=True)
# Format error message for user-friendly display
formatted_error = _format_validation_error(str(e))
# Create failure result
@@ -349,15 +349,16 @@ class PlaceholderOrchestrator:
if not entity_type or not entity_name:
raise ValueError(f"Failed to parse placeholder: {placeholder}")
# Build lookup key
lookup_key = f"{entity_type}:{entity_name}"
# Build lookup key (lowercase for case-insensitive matching)
lookup_key = f"{entity_type}:{entity_name.lower()}"
name_key = entity_name.lower()
# Try exact match first
# Try exact match first (keys are stored lowercase)
if lookup_key in self.execution_context:
context_entry = self.execution_context[lookup_key]
elif entity_name in self.execution_context:
elif name_key in self.execution_context:
# Fallback to name-only lookup
context_entry = self.execution_context[entity_name]
context_entry = self.execution_context[name_key]
else:
raise ValueError(
f"Entity '{entity_name}' (type: {entity_type}) not found in execution context. "
@@ -498,11 +499,24 @@ IMPORTANT:
# Build tool if not cached
if tool_name not in self.tools_cache:
entity_type = action.get("entity_type")
if entity_type:
tools = self.chatbot._build_method_tools(entity_type, self.method_executor, self.context)
for tool in tools:
self.tools_cache[tool.name] = tool
# Special cases where tool name doesn't follow {category}_{method} pattern
SPECIAL_TOOL_CATEGORIES = {
"create_epic": "workitems",
"update_epic": "workitems",
}
# Extract category from tool name
if tool_name in SPECIAL_TOOL_CATEGORIES:
category = SPECIAL_TOOL_CATEGORIES[tool_name]
else:
# Standard pattern: {category}_{method}
# e.g., "initiatives_create_label" -> "initiatives"
category = tool_name.split("_")[0] if "_" in tool_name else tool_name
log.info(f"Building tools for category '{category}' (tool: {tool_name})")
tools = self.chatbot._build_method_tools(category, self.method_executor, self.context)
for tool in tools:
self.tools_cache[tool.name] = tool
# Get the tool
tool = self.tools_cache.get(tool_name)
@@ -510,7 +524,13 @@ IMPORTANT:
raise ValueError(f"Tool '{tool_name}' not found")
# Execute the tool
result = await tool.ainvoke(args)
log.info(f"[DEBUG] About to invoke tool '{tool_name}' with args: {args}")
try:
result = await tool.ainvoke(args)
log.info(f"[DEBUG] Tool '{tool_name}' invocation completed. Result type: {type(result)}")
except Exception as tool_error:
log.error(f"[DEBUG] Tool '{tool_name}' raised exception during ainvoke: {tool_error}", exc_info=True)
raise
if not isinstance(result, dict):
raise ValueError(f"Tool '{tool_name}' must return a dict, got {type(result)}")
@@ -560,20 +580,22 @@ IMPORTANT:
return
# Get the planned name from action args
planned_name = action.get("args", {}).get("name")
# Check both 'name' and 'display_name' (properties use display_name)
planned_name = action.get("args", {}).get("name") or action.get("args", {}).get("display_name")
# Build all possible lookup keys
# Build all possible lookup keys (use lowercase for case-insensitive matching)
keys = [
f"{entity_type}:{actual_name}", # e.g., "project:Motor Bike47119"
actual_name, # Allow lookup by actual name alone
f"{entity_type}:{actual_name.lower()}", # e.g., "property:severity"
actual_name.lower(), # Allow lookup by actual name alone
]
# IMPORTANT: Also store under planned name if different from actual
# This handles SDK name modifications on conflict (409)
if planned_name and planned_name != actual_name:
keys.append(f"{entity_type}:{planned_name}") # e.g., "project:Motor Bike"
keys.append(planned_name) # Allow lookup by planned name
log.info(f"⚠️ Name mismatch: planned='{planned_name}' vs actual='{actual_name}'. " f"Storing under both names for lookup.")
# This handles SDK name modifications on conflict (409) AND case differences
if planned_name:
keys.append(f"{entity_type}:{planned_name.lower()}") # e.g., "property:severity"
keys.append(planned_name.lower()) # Allow lookup by planned name
if planned_name.lower() != actual_name.lower():
log.info(f"⚠️ Name mismatch: planned='{planned_name}' vs actual='{actual_name}'. " f"Storing under both names for lookup.")
# Store under all keys
for key in keys:
@@ -583,7 +605,7 @@ IMPORTANT:
def _can_resolve_from_context(self, entity_type: Optional[str], entity_name: Optional[str]) -> bool:
"""
Check if we have this entity in our execution context.
Check if we have this entity in our execution context (case-insensitive).
Args:
entity_type: Type of entity (e.g., "project")
@@ -595,9 +617,10 @@ IMPORTANT:
if not entity_type or not entity_name:
return False
# Check if we have this entity stored
key = f"{entity_type}:{entity_name}"
return key in self.execution_context or entity_name in self.execution_context
# Check if we have this entity stored (case-insensitive)
key = f"{entity_type}:{entity_name.lower()}"
name_key = entity_name.lower()
return key in self.execution_context or name_key in self.execution_context
def _is_placeholder(self, value: Any) -> bool:
"""Check if value is a placeholder string."""
+156 -28
View File
@@ -78,11 +78,12 @@ TOOL_NAME_TO_CATEGORY_MAP: Dict[str, Dict[str, str]] = {
"pages_create_project_page": {"entity_type": "page", "action_type": "create", "front_facing_name": "Create Project Page"},
"pages_create_workspace_page": {"entity_type": "page", "action_type": "create", "front_facing_name": "Create Workspace Page"},
# Projects
"projects_archive": {"entity_type": "project", "action_type": "archive", "front_facing_name": "Archive Project"},
"projects_create": {"entity_type": "project", "action_type": "create", "front_facing_name": "Create Project"},
"projects_unarchive": {"entity_type": "project", "action_type": "unarchive", "front_facing_name": "Unarchive Project"},
"projects_delete": {"entity_type": "project", "action_type": "delete", "front_facing_name": "Delete Project"},
"projects_update": {"entity_type": "project", "action_type": "update", "front_facing_name": "Update Project"},
"projects_retrieve": {"entity_type": "project", "action_type": "retrieve", "front_facing_name": "Retrieve Project"},
"projects_get_features": {"entity_type": "project", "action_type": "get", "front_facing_name": "Get Project Features"},
"projects_update_features": {"entity_type": "project", "action_type": "update", "front_facing_name": "Update Project Features"},
# Properties
"properties_create": {"entity_type": "property", "action_type": "create", "front_facing_name": "Create Property"},
"properties_create_option": {"entity_type": "property", "action_type": "create_option", "front_facing_name": "Create Property Option"},
@@ -98,6 +99,52 @@ TOOL_NAME_TO_CATEGORY_MAP: Dict[str, Dict[str, str]] = {
"types_create": {"entity_type": "type", "action_type": "create", "front_facing_name": "Create Type"},
"types_delete": {"entity_type": "type", "action_type": "delete", "front_facing_name": "Delete Type"},
"types_update": {"entity_type": "type", "action_type": "update", "front_facing_name": "Update Type"},
# Initiatives (SDK v0.2.1)
"initiatives_create": {"entity_type": "initiative", "action_type": "create", "front_facing_name": "Create Initiative"},
"initiatives_list": {"entity_type": "initiative", "action_type": "list", "front_facing_name": "List Initiatives"},
"initiatives_retrieve": {"entity_type": "initiative", "action_type": "retrieve", "front_facing_name": "Retrieve Initiative"},
"initiatives_update": {"entity_type": "initiative", "action_type": "update", "front_facing_name": "Update Initiative"},
"initiatives_delete": {"entity_type": "initiative", "action_type": "delete", "front_facing_name": "Delete Initiative"},
"initiatives_create_label": {"entity_type": "initiative_label", "action_type": "create", "front_facing_name": "Create Initiative Label"},
"initiatives_list_labels": {"entity_type": "initiative_label", "action_type": "list", "front_facing_name": "List Initiative Labels"},
"initiatives_retrieve_label": {"entity_type": "initiative_label", "action_type": "retrieve", "front_facing_name": "Retrieve Initiative Label"},
"initiatives_update_label": {"entity_type": "initiative_label", "action_type": "update", "front_facing_name": "Update Initiative Label"},
"initiatives_delete_label": {"entity_type": "initiative_label", "action_type": "delete", "front_facing_name": "Delete Initiative Label"},
"initiatives_add_labels": {"entity_type": "initiative", "action_type": "add", "front_facing_name": "Add Labels to Initiative"},
"initiatives_remove_labels": {"entity_type": "initiative", "action_type": "remove", "front_facing_name": "Remove Labels from Initiative"},
"initiatives_add_projects": {"entity_type": "initiative", "action_type": "add", "front_facing_name": "Add Projects to Initiative"},
"initiatives_list_projects": {"entity_type": "initiative", "action_type": "list", "front_facing_name": "List Initiative Projects"},
"initiatives_remove_projects": {"entity_type": "initiative", "action_type": "remove", "front_facing_name": "Remove Projects from Initiative"},
"initiatives_add_epics": {"entity_type": "initiative", "action_type": "add", "front_facing_name": "Add Epics to Initiative"},
"initiatives_list_epics": {"entity_type": "initiative", "action_type": "list", "front_facing_name": "List Initiative Epics"},
"initiatives_remove_epics": {"entity_type": "initiative", "action_type": "remove", "front_facing_name": "Remove Epics from Initiative"},
# Teamspaces (SDK v0.2.1)
"teamspaces_create": {"entity_type": "teamspace", "action_type": "create", "front_facing_name": "Create Teamspace"},
"teamspaces_list": {"entity_type": "teamspace", "action_type": "list", "front_facing_name": "List Teamspaces"},
"teamspaces_retrieve": {"entity_type": "teamspace", "action_type": "retrieve", "front_facing_name": "Retrieve Teamspace"},
"teamspaces_update": {"entity_type": "teamspace", "action_type": "update", "front_facing_name": "Update Teamspace"},
"teamspaces_delete": {"entity_type": "teamspace", "action_type": "delete", "front_facing_name": "Delete Teamspace"},
"teamspaces_add_members": {"entity_type": "teamspace", "action_type": "add", "front_facing_name": "Add Members to Teamspace"},
"teamspaces_list_members": {"entity_type": "teamspace", "action_type": "list", "front_facing_name": "List Teamspace Members"},
"teamspaces_remove_members": {"entity_type": "teamspace", "action_type": "remove", "front_facing_name": "Remove Members from Teamspace"},
"teamspaces_add_projects": {"entity_type": "teamspace", "action_type": "add", "front_facing_name": "Add Projects to Teamspace"},
"teamspaces_list_projects": {"entity_type": "teamspace", "action_type": "list", "front_facing_name": "List Teamspace Projects"},
"teamspaces_remove_projects": {"entity_type": "teamspace", "action_type": "remove", "front_facing_name": "Remove Projects from Teamspace"},
# Stickies (SDK v0.2.1)
"stickies_create": {"entity_type": "sticky", "action_type": "create", "front_facing_name": "Create Sticky Note"},
"stickies_list": {"entity_type": "sticky", "action_type": "list", "front_facing_name": "List Sticky Notes"},
"stickies_retrieve": {"entity_type": "sticky", "action_type": "retrieve", "front_facing_name": "Retrieve Sticky Note"},
"stickies_update": {"entity_type": "sticky", "action_type": "update", "front_facing_name": "Update Sticky Note"},
"stickies_delete": {"entity_type": "sticky", "action_type": "delete", "front_facing_name": "Delete Sticky Note"},
# Customers (SDK v0.2.1)
"customers_create": {"entity_type": "customer", "action_type": "create", "front_facing_name": "Create Customer"},
"customers_list": {"entity_type": "customer", "action_type": "list", "front_facing_name": "List Customers"},
"customers_retrieve": {"entity_type": "customer", "action_type": "retrieve", "front_facing_name": "Retrieve Customer"},
"customers_update": {"entity_type": "customer", "action_type": "update", "front_facing_name": "Update Customer"},
"customers_delete": {"entity_type": "customer", "action_type": "delete", "front_facing_name": "Delete Customer"},
# Workspaces (SDK v0.2.2 - features)
"workspaces_get_features": {"entity_type": "workspace", "action_type": "get", "front_facing_name": "Get Workspace Features"},
"workspaces_update_features": {"entity_type": "workspace", "action_type": "update", "front_facing_name": "Update Workspace Features"},
# Workitems and Epics
"create_epic": {"entity_type": "epic", "action_type": "create", "front_facing_name": "Create Epic"},
"update_epic": {"entity_type": "epic", "action_type": "update", "front_facing_name": "Update Epic"},
@@ -636,7 +683,7 @@ work_tree_instructions_normal_response = """
**IF the user's request is informational/retrieval-only (questions, searches, listing, checking status):**
1. Use retrieval/search tools to gather the requested information
2. Provide a detailed, elaborate, and neatly formatted answer in the content section based on the retrieved data. Do NOT be brief.
3. Do NOT plan any modifying actions - just return tool_calls for retrieval, then answer in your content
3. Do NOT plan any modifying actions - just invoke retrieval tools, then answer in your content
4. **Formatting Requirements**:
While formatting the answer in the final content section, use the following rules:
- Use "work-item" (not "issue") and "unique key" (not "Issue ID") terminology
@@ -649,8 +696,8 @@ work_tree_instructions_normal_response = """
**IF the user's request requires modifying data (create, update, delete, move, assign, etc.):**
1. Use retrieval/search tools to gather necessary information (IDs, etc.)
2. **AND** plan at least one MODIFYING ACTION using tool_calls (create, update, delete, add, remove, move, etc.)
3. You CANNOT stop after just searching/retrieving - you MUST plan the modifying actions with tool_calls
2. **AND** invoke at least one MODIFYING ACTION tool (create, update, delete, add, remove, move, etc.)
3. You CANNOT stop after just searching/retrieving - you MUST invoke the modifying action tools
4. Provide a very brief summary of what you planned in your content
5. **Formatting Requirements**:
While formatting the action plan in the final content section, use the following rules:
@@ -667,14 +714,14 @@ work_tree_instructions_normal_response = """
- Do NOT create workaround entities (like workitems) to satisfy these requests
- Do NOT plan any actions - just provide the explanation in your content
Note: The system uses absence of tool_calls as the signal to stop and deliver your content as the final answer. For modification requests, include at least one write tool_call in your plan.
Note: The system detects when you stop invoking tools and delivers your content as the final answer. For modification requests, you MUST invoke at least one write action tool.
""" # noqa E501
work_tree_instructions_app_response = f"""
**IF the user's request is informational/retrieval-only (questions, searches, listing, checking status):**
1. Use retrieval/search tools to gather the requested information
2. Provide a detailed, and elaborate answer in PLAIN TEXT ONLY in the 'text' field in the provide_final_answer_for_app tool based on the retrieved data. Do NOT be brief.
3. Do NOT plan any modifying actions - just return tool_calls for retrieval, then call provide_final_answer_for_app
3. Do NOT plan any modifying actions - just invoke retrieval tools, then call provide_final_answer_for_app
4. **Formatting Requirements - CRITICAL FOR EXTERNAL APP (e.g., Slack) RENDERING**:
While generating the answer in the 'text' field in the provide_final_answer_for_app tool, use the following rules:
- **ABSOLUTELY NO MARKDOWN FORMATTING**: Do not use **bold**, *italic*, headers, or any markdown syntax
@@ -697,8 +744,8 @@ work_tree_instructions_app_response = f"""
**IF the user's request requires modifying data (create, update, delete, move, assign, etc.):**
1. Use retrieval/search tools to gather necessary information (IDs, etc.)
2. **AND** plan at least one MODIFYING ACTION using tool_calls (create, update, delete, add, remove, move, etc.)
3. You CANNOT stop after just searching/retrieving - you MUST plan the modifying actions with tool_calls
2. **AND** invoke at least one MODIFYING ACTION tool (create, update, delete, add, remove, move, etc.)
3. You CANNOT stop after just searching/retrieving - you MUST invoke the modifying action tools
4. Provide a very brief summary of what you planned in your content
5. **Formatting Requirements**:
While formatting the action plan in the final content section, use the following rules:
@@ -714,7 +761,7 @@ work_tree_instructions_app_response = f"""
- Do NOT create workaround entities (like workitems) to satisfy these requests
- Do NOT plan any actions - just provide the explanation in your content
Note: This is a response for consumption by an external app. So, calling provide_final_answer_for_app signals completion for retrieval-only requests. For modification requests, include at least one write tool_call in your plan.
Note: This is a response for consumption by an external app. Calling provide_final_answer_for_app signals completion for retrieval-only requests. For modification requests, you MUST invoke at least one write action tool.
""" # noqa E501
@@ -777,6 +824,32 @@ Use retrieval tools to gather information, then plan the modifying actions based
- Do not try to execute actions - only plan them
- For multi-property updates, resolve all required IDs first, then set all properties in a single tool call
**MANDATORY REASONING AND COMMUNICATION (CRITICAL - REQUIRED FOR EVERY TOOL CALL):**
- **BEFORE EACH TOOL CALL**: You MUST explain your reasoning and intent
- State what information you're trying to gather or what action you're planning
- Explain why this tool is necessary for completing the user's request
- Describe what you expect to get from the tool and how you'll use it
- Example: "The user wants to check workitems assigned to Anil. First, I need to search for the user 'Anil' to get their ID, then I'll use that ID to filter workitems." # noqa: E501
- **AFTER EACH TOOL CALL**: You MUST provide a brief summary of what you learned
- Summarize key information obtained from the tool
- Explain how this information helps with the next step
- If the tool returned unexpected results, explain how you'll adapt
- Example: "Found user Anil Kumar with ID xyz-123. Now I'll use this ID to search for workitems assigned to them." # noqa: E501
- **THINKING OUT LOUD**: Express your thought process naturally
- Share your understanding of the user's request
- Explain your strategy for accomplishing the task
- Mention any assumptions you're making
- This reasoning is MANDATORY and helps with debugging and understanding your decision-making process
- NEVER skip the reasoning - it's essential for transparency and troubleshooting
**CRITICAL: OPTIONAL PARAMETERS POLICY (APPLIES TO ALL TOOLS):**
- Only provide optional parameters that are EXPLICITLY requested by the user or clearly implied by their intent
- DO NOT auto-fill optional fields with "sensible defaults" or inferred values
- **DO NOT ask the user for clarification if optional parameters are missing.**
- **IF an optional parameter is not provided, you MUST leave it empty and let the API use its default.**
- This applies to ALL optional fields across ALL entity types (description, priority, state, assignees, dates, etc.)
- When in doubt, omit the field.
**WORKSPACE-LEVEL QUERIES (EFFICIENCY):**
|- When query is related to workitems and spans ALL projects without specifying a particular project or projects
| - DO NOT: call `list_member_projects` then query each project with `structured_db_tool` separately
@@ -787,28 +860,76 @@ Use retrieval tools to gather information, then plan the modifying actions based
|- For project-specific queries, include the specific `project_id`
**PROJECT FEATURES CHECK (CRITICAL - MANDATORY BEFORE CREATING PROJECT-SCOPED ENTITIES):**
- Cycles, modules, pages, workitem types, views, intake, and time-tracking (worklogs) are project-level features that are enabled/disabled on a per-project basis.
- **MANDATORY WORKFLOW**: Before creating ANY of these entities (cycles_create, modules_create, pages_create_*, worklogs_create, etc.), you MUST:
1. First get the project_id (via search_project_by_name or search_project_by_identifier if not already known)
2. **THEN** call `projects_retrieve` with that project_id to check if the feature is enabled
3. **AFTER checking**: You MUST plan the required actions using tool_calls:
- If the feature IS enabled: Plan the creation action (e.g., `cycles_create`, `modules_create`, `worklogs_create`)
- If the feature is NOT enabled: Plan BOTH `projects_update` (to enable the feature) AND the creation action (e.g., `cycles_create`, `modules_create`, `worklogs_create`)
4. **CRITICAL**: After `projects_retrieve` completes, you CANNOT stop - you MUST continue planning the modifying actions. Do NOT return only text - you MUST return tool_calls for the planned actions.
5. **REMEMBER**: `projects_retrieve` is ONLY for information gathering. The user's actual request (create cycle/module/page/worklog) is NOT complete until you plan the creation action with tool_calls. Do NOT stop after retrieval - the task is incomplete without planning the creation.
- **CRITICAL**: This check is NON-NEGOTIABLE. Never skip the `projects_retrieve` step when creating cycles, modules, pages, or other project-scoped features.
- **CRITICAL**: After retrieving project features, you MUST plan the actions - do NOT stop after retrieval. The user requested a modification (create cycle/module/page/worklog), so you MUST plan it with tool_calls.
- **EXCEPTION (NEW PROJECT IN CURRENT PLAN)**: If the target project is being CREATED in this same plan and does not yet have a real UUID, do NOT call `projects_retrieve` during planning. Use placeholders for downstream actions and defer any feature checks until after execution or when working with an existing project that has a UUID.
- Cycles, modules, pages, workitem types, views, intake, epics (a special workitem type), and time-tracking (worklogs) are project-level features that are enabled/disabled on a per-project basis.
- **MANDATORY WORKFLOW**: Before creating ANY of these entities (cycles_create, create_epic, modules_create, pages_create_*, worklogs_create, intake_create, etc.), you MUST:
1. First get the project_id (UUID) (via search_project_by_name or search_project_by_identifier (Identifier looks like 'PROJ', 'WEB' etc. It is not a UUID) if not already known)
2. **THEN** call `projects_retrieve` with that project_id to check if the feature is enabled.
- **WARNING**: Do NOT stop to ask for clarification on optional entity fields (e.g., description, priority) before this check.
- **WARNING**: You must perform this check even if you think you need more information for the creaton.
- **WARNING**: You must perform this check even if you already have all required IDs (issue_id, project_id, etc.). Having IDs does NOT exempt you from checking if the feature is enabled.
3. **AFTER checking**: You MUST invoke the required action tools:
- If the feature IS enabled: Plan the creation action (e.g., `cycles_create`, `create_epic`, `modules_create`, `types_create`, `worklogs_create`, `intake_create`, etc.)
- If the feature is NOT enabled: Plan BOTH `projects_update` (to enable the feature) AND the creation action (e.g., `cycles_create`, `create_epic`, `modules_create`, `types_create`, `worklogs_create`, `intake_create`, etc.)
4. **CRITICAL**: After `projects_retrieve` completes, you CANNOT stop - you MUST continue invoking the modifying action tools. Do NOT return only text - you MUST invoke the action tools.
- **CRITICAL**: This check is NON-NEGOTIABLE. Never skip the `projects_retrieve` step when creating cycles, modules, pages, types, intake items, or other project-scoped features.
- **CONSEQUENCE**: Skipping `projects_retrieve` and directly calling creation tools (e.g., worklogs_create, cycles_create, intake_create) WILL result in a 404 error if the feature is disabled. Always check first.
- **MULTI-ACTION REQUESTS**: When handling requests with multiple actions, if ANY action requires a feature check, you MUST call `projects_retrieve` FIRST before planning any of the actions.
- **EXCEPTION (NEW PROJECT IN CURRENT PLAN)**: If the target project is being CREATED in this same plan and does not yet have a real UUID:
- Do NOT call `projects_retrieve` during planning (the project doesn't exist yet to retrieve)
- Instead, you MUST enable the required feature flag during `projects_create` itself
- **Example**: If planning to create a project AND a cycle in the same plan:
1. Call `projects_create` with `cycle_view=True` (to enable cycles feature)
2. Call `cycles_create` with `project_id="<id of project: project-name>"`
- **Available feature flags for projects_create**:
- `epics` (boolean): Enable epics feature
- `cycle_view` (boolean): Enable cycles feature
- `module_view` (boolean): Enable modules feature
- `page_view` (boolean): Enable pages feature
- `intake_view` (boolean): Enable intake feature
- `is_issue_type_enabled` (boolean): Enable workitem types feature
- `is_time_tracking_enabled` (boolean): Enable time-tracking (worklogs) feature
- `issue_views_view` (boolean): Enable workitem views feature
- Available tools:
- `projects_retrieve` tool to get details of the project features (MUST call before creating project-scoped entities)
- `projects_update` tool to update the project features (MUST include in plan if feature needs to be enabled)
- `projects_create` tool to create a new project with any/all of these features based on the user's request
- `projects_retrieve` tool to get details of the project features (MUST call before creating project-scoped entities for EXISTING projects)
- `projects_update` tool to update the project features (MUST include in plan if feature needs to be enabled for EXISTING projects)
- `projects_create` tool to create a new project with any/all of these features enabled based on the user's request
**HARD CONSTRAINTS FOR TOOL CALLS (NON-NEGOTIABLE):**
- If the request involves modification of data like creating, updating, adding, removing, moving, assigning, archiving, or unarchiving: you MUST return one or more tool_calls for the corresponding action tools (e.g., cycles_create, workitems_create, modules_add_work_items). Returning only text is incorrect.
**WORKSPACE FEATURES CHECK (CRITICAL - MANDATORY BEFORE CREATING WORKSPACE-SCOPED ENTITIES):**
- Workspace-level features are enabled/disabled on a per-workspace basis and fall into two categories:
- **Features with entity operations**: initiatives, teams (teamspaces), customers - these have create/update/delete tools
- **Feature toggles only**: wiki, Plane AI (formerly, Pi - Plane Intelligence), project_grouping - these are settings without entity operations
- **MANDATORY WORKFLOW for features with entity operations** (initiatives, teams, customers):
Before creating entities via `initiatives_create`, `teamspaces_create`, or `customers_create`, you MUST:
1. **FIRST** call `workspaces_get_features` to check if the required feature is enabled
2. **AFTER checking**: You MUST invoke the required action tools:
- If the feature IS enabled: Plan the creation action (e.g., `initiatives_create`, `teamspaces_create`, `customers_create`)
- If the feature is NOT enabled: Plan BOTH `workspaces_update_features` (to enable the feature) AND the creation action
3. **CRITICAL**: After `workspaces_get_features` completes, you CANNOT stop - you MUST continue invoking the modifying action tools. Do NOT return only text - you MUST invoke the action tools.
- **CRITICAL**: This check is NON-NEGOTIABLE. Never skip the `workspaces_get_features` step when creating initiatives, teamspaces, or customers.
- **Feature-to-Entity/Operation Mapping**:
- `initiatives` feature `initiatives_create`, `initiatives_update`, `initiatives_delete`, `initiatives_add_projects`, etc.
- `teams` feature `teamspaces_create`, `teamspaces_update`, `teamspaces_delete`, `teamspaces_add_members`, etc.
- `customers` feature `customers_create`, `customers_update`, `customers_delete`, etc.
- `wiki` feature Feature toggle only (enables wiki functionality in UI, no entity operations available)
- `pi` feature Feature toggle only (enables Plane AI in UI, no entity operations available)
- `project_grouping` feature Feature toggle only (allows grouping projects in workspace UI, no entity operations available)
- **Available feature flags for workspaces_update_features**:
- `initiatives` (boolean): Enable/disable initiatives feature
- `teams` (boolean): Enable/disable teamspaces feature
- `customers` (boolean): Enable/disable customers feature
- `wiki` (boolean): Enable/disable wiki feature toggle
- `pi` (boolean): Enable/disable Plane AI feature toggle
- `project_grouping` (boolean): Enable/disable project grouping feature toggle
- Available tools:
- `workspaces_get_features`: Get current workspace feature flags (MUST call before creating initiatives/teamspaces/customers)
- `workspaces_update_features`: Enable/disable workspace features (MUST include in plan if feature needs to be enabled)
**HARD CONSTRAINTS FOR TOOL INVOCATION (NON-NEGOTIABLE):**
- If the request involves modification of data like creating, updating, adding, removing, moving, assigning, archiving, or unarchiving: you MUST invoke the corresponding action tools (e.g., cycles_create, workitems_create, modules_add_work_items). Returning only text is incorrect.
- For modification requests, keep text content brief - do NOT provide meta commentary like "I will plan..." or ask user to click 'Confirm'
- System handles approval automatically
- If required information is missing, call ask_for_clarification instead of skipping tool_calls.
- If required information is missing, call ask_for_clarification instead of skipping tool invocation.
**RETRIEVAL RESULT RELEVANCE IN PLANNING ACTIONS FOR MODIFYING REQUESTS (CRITICAL):**
@@ -958,6 +1079,13 @@ After resolving project_id for cycles/modules/pages creation:
**EFFICIENCY RULE**: Always try to set as many properties as possible during creation to minimize API calls.
**MANDATORY REQUIREMENT - REASONING FOR EVERY TOOL CALL:**
You MUST provide clear reasoning in your response content BEFORE and AFTER each tool call:
- BEFORE: Explain what you're about to do and why (e.g., "Let me first find user Anil's details to get their ID...")
- AFTER: Summarize what you found and your next step (e.g., "Found Anil Kumar (ID: xyz-123). Now searching for workitems assigned to them...")
- This reasoning is NOT optional - it's required for transparency and helps users understand your process
- Even for simple searches, explain your thinking (e.g., "Searching for project 'Mobile' to get its UUID for creating workitems...")
- Provide this in the content field surrounding your tool invocations
**IMPORTANT**: Analyze the user's request carefully to identify ALL required actions, not just the obvious ones.
+8 -37
View File
@@ -23,9 +23,9 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from pi import logger
from pi import settings
from pi.agents.sql_agent.tools import construct_entity_urls_from_db
from pi.agents.sql_agent.tools import extract_ids_from_sql_result
from pi.agents.sql_agent.tools import format_as_bullet_points
from pi.agents.sql_agent.helpers import construct_entity_urls_from_db
from pi.agents.sql_agent.helpers import extract_ids_from_sql_result
from pi.agents.sql_agent.helpers import format_as_bullet_points
from pi.app.models.enums import MessageMetaStepType
# Import new modular tools from actions service
@@ -67,10 +67,6 @@ class ChatKit(AttachmentMixin):
from pi.services.llm.llms import create_anthropic_llm
from pi.services.llm.llms import create_openai_llm
# Store original switch_llm value to determine if custom model was requested
# GPT-5 variants and gpt-5.1 are handled separately, not as custom models
use_custom_model = switch_llm is not None and not (switch_llm.startswith("gpt-5") or switch_llm == "gpt-5.1")
# Model name mapping for user-friendly names to actual LiteLLM model names
claude_model_name_mapping = {
"claude-sonnet-4": settings.llm_model.LITE_LLM_CLAUDE_SONNET_4,
@@ -136,36 +132,11 @@ class ChatKit(AttachmentMixin):
TOOL_LLM = switch_llm
tool_config = LLMConfig(model=TOOL_LLM, temperature=0.2, streaming=tool_llm_streaming)
# Initialize LLMs using LLMFactory with switch_llm support
if use_custom_model:
# For custom models, try LiteLLM configs with model-type naming convention
self.llm = llms.LLMFactory.get_default_llm(f"{switch_llm}-default")
self.stream_llm = llms.LLMFactory.get_stream_llm(f"{switch_llm}-stream")
self.decomposer_llm = llms.LLMFactory.get_decomposer_llm(f"{switch_llm}-decomposer")
self.fast_llm = llms.LLMFactory.get_fast_llm(streaming=False, model_name=f"{switch_llm}-fast")
else:
# Use default global instances, but for GPT-5 variants create dynamic instances
if switch_llm == "gpt-5-standard":
self.llm = llms.LLMFactory.get_default_llm("gpt5_standard_default")
self.stream_llm = llms.LLMFactory.get_stream_llm("gpt5_standard_stream")
self.decomposer_llm = llms.LLMFactory.get_decomposer_llm("gpt5_standard_default")
self.fast_llm = llms.LLMFactory.get_fast_llm(streaming=False, model_name="gpt5_standard_default")
elif switch_llm == "gpt-5-fast":
self.llm = llms.LLMFactory.get_default_llm("gpt5_fast_default")
self.stream_llm = llms.LLMFactory.get_stream_llm("gpt5_fast_stream")
self.decomposer_llm = llms.LLMFactory.get_decomposer_llm("gpt5_fast_default")
self.fast_llm = llms.LLMFactory.get_fast_llm(streaming=False, model_name="gpt5_fast_default")
elif switch_llm == "gpt-5.1":
self.llm = llms.LLMFactory.get_default_llm("gpt5_1_default")
self.stream_llm = llms.LLMFactory.get_stream_llm("gpt5_1_stream")
self.decomposer_llm = llms.LLMFactory.get_decomposer_llm("gpt5_1_default")
self.fast_llm = llms.LLMFactory.get_fast_llm(streaming=False, model_name="gpt5_1_default")
else:
# Use default global instances
self.llm = llms.llm
self.stream_llm = llms.stream_llm
self.decomposer_llm = llms.decomposer_llm
self.fast_llm = llms.fast_llm
# Initialize LLMs using LLMFactory with centralized model name mapping
self.llm = llms.LLMFactory.get_default_llm(switch_llm)
self.stream_llm = llms.LLMFactory.get_stream_llm(switch_llm)
self.decomposer_llm = llms.LLMFactory.get_decomposer_llm(switch_llm)
self.fast_llm = llms.LLMFactory.get_fast_llm(streaming=False, model_name=switch_llm)
self.switch_llm = switch_llm
# Get chat LLM - reasoning effort is now determined by model name
+18 -15
View File
@@ -29,7 +29,7 @@ CORE BUILDING BLOCKS
10. Team Drafts & Inbox - Drafts store half-written work items; Inbox is a catch-all notification and mention feed.
AI ASSISTANT
11. Plane AI (Formerly called, Pi a moniker for Plane Intelligence) - AI-powered assistant that helps users interact with Plane using natural language. Pi can search work items, analyze project data, access documentation, and provide insights through conversational queries. It also has action capabilities like create, update, delete, assign, move, etc.
11. Plane AI (Formerly called Pi, a moniker for Plane Intelligence) - AI-powered assistant that helps users interact with Plane using natural language. Pi can search work items, analyze project data, access documentation, and provide insights through conversational queries. It also has action capabilities like create, update, delete, assign, move, etc.
PROJECT & WORK MANAGEMENT
12. Work Item Types - schema-driven custom types with per-type fields (replaces "Issue Types").
@@ -59,18 +59,17 @@ VISUALIZATION & INSIGHT
NAVIGATION & PRODUCTIVITY
29. Power K - global command palette (/Ctrl + K) for fuzzy jumping and quick actions.
30. Hyper Mode - optional local SQLite cache that slashes load times for big workspaces.
31. Mobile Apps - Android 5+ and iOS 13+ companion apps with project, work-item, cycles, pages & inbox support.
30. Mobile Apps - Android 5+ and iOS 13+ companion apps with project, work-item, cycles, pages & inbox support.
INTEGRATION & EXTENSIBILITY
32. Importers - migrate from Jira, Linear, Asana, or CSV.
33. Integrations - GitHub (PR Work Item sync), GitLab, Slack slash-commands + notifications.
34. API & Webhooks - REST-style JSON API plus outgoing webhooks.
35. Self-Host vs Cloud - run Plane Cloud (SaaS) or deploy the open-source stack on-prem; Hyper Mode requires HTTPS.
31. Importers - migrate from Jira, Linear, Asana, or CSV.
32. Integrations - GitHub (PR Work Item sync), GitLab, Slack slash-commands + notifications.
33. SDK, API & Webhooks - Plane SDK (Python and Node.js), REST-style JSON API plus outgoing webhooks.
34. Self-Host vs Cloud - run Plane Cloud (SaaS) or deploy the open-source stack on-prem.
PERMISSIONS & BILLING
36. Roles - Admin, Member, Guest per workspace/project; fine-grained on features.
37. Billing Plans - Free, Pro, Enterprise; feature flags like Epics, Initiatives, Dashboards noted as paid-only.
35. Roles - Admin, Member, Guest per workspace/project; fine-grained on features.
36. Billing Plans - Free, Pro, Business, Enterprise; feature flags like Epics, Initiatives, Dashboards noted as paid-only.
TERMINOLOGY BRIDGE cross-tool aliases
(Use this glossary to map Plane objects to the familiar terms you'll see in other tools or in general)
@@ -78,7 +77,7 @@ TERMINOLOGY BRIDGE — cross-tool aliases
Work Items tasks, issues, tickets, user stories
States status buckets (Backlog/Todo, In Progress, Done/Closed)
Cycles sprints, iterations
Modules components, milestones, feature buckets
Modules components, feature buckets
Epics large user stories / epics
Initiatives programs, portfolio objectives
Projects projects, boards
@@ -139,22 +138,26 @@ Your task: Based on the user's intent and any advisory text (like method lists)
- labels: Create/list/update/delete labels
- states: Create/list/update/delete states
- modules: Create/list/update/delete modules, add/remove workitems to/from modules
- pages: Create and manage project and workspace pages/documentation
- assets: Create/list/update/delete assets
- pages: Create and manage project and workspace pages/documentation (rich text, fonts, images, styles)
- users: Get current user information
- intake: Handle intake forms, guest submissions, triage workflow
- members: Workspace and project member management, listings
- activity: Track work item activities, history, and audit logs
- attachments: File attachments on work items, upload and management
- comments: Comments and discussions on work items
- links: External links and references on work items
- properties: Custom properties and fields for work items
- types: Custom work item types (bug, task, story, etc.)
- worklogs: Time tracking and work logs
- initiatives: Create/list/update/delete initiatives (cross-project goal containers)
- teamspaces: Manage teamspaces (team containers for projects and cycles)
- stickies: Create/list/update/delete sticky notes (short plain text notes, like 3M stickers, no rich media)
- customers: Manage customer records and CRM integrations
- workspaces: Workspace-level operations and feature management
- retrieval_tools: text2sql, vector_search_tool, pages_search_tool, docs_search_tool
Rules:
- "wiki", "knowledge base", "kb", "handbook", "runbook", and "notes" are all synonyms for pages. Route these to the pages category, not projects.
- "wiki", "knowledge base", "kb", "handbook", "runbook", and "notes" (BUT NOT "sticky notes") are all synonyms for pages. Route these to the pages category, not projects.
- "sticky", "stickies", "sticky note" should ALWAYS be routed to the stickies category, not pages.
- If the user says "create a page/wiki" without specifying a project, prefer the pages category and plan a workspace-level page.
- If a project is explicitly mentioned (by name or identifier), route to pages and plan a project-level page (resolve project UUID first).
- Select multiple categories when the intent spans multiple domains (e.g., list work-items then create a cycle).
@@ -198,7 +201,7 @@ You MUST return a JSON object with a "selections" key containing an array of sel
**Rules:**
- ALWAYS wrap selections in a "selections" array, even for a single category
- If no categories are appropriate, return: {{{{"selections": []}}}}
- Valid category values: workitems, projects, cycles, labels, states, modules, pages, assets, users, intake, members, activity, attachments, comments, links, properties, types, worklogs, retrieval_tools
- Valid category values: workitems, projects, cycles, labels, states, modules, pages, users, intake, members, activity, comments, links, properties, types, worklogs, initiatives, teamspaces, stickies, customers, workspaces, retrieval_tools
- No explanation outside the JSON structure
""" # noqa: E501
+112 -28
View File
@@ -452,53 +452,137 @@ _LITE_LLM_CONFIGS = {
}
def _get_config_name_for_model(model_name: Optional[str], config_type: str) -> Optional[str]:
"""Map user-facing model name to internal config name.
Args:
model_name: User-facing model name (e.g., "gpt-5.2", "claude-sonnet-4-0", None)
config_type: Type of config ("default", "stream", "decomposer", "fast")
Returns:
Internal config name to lookup in _DEFAULT_CONFIGS, _ANTHROPIC_CONFIGS, etc.
Returns None if no mapping exists (will use global defaults).
"""
if not model_name:
return config_type # Use base config type: "default", "stream", etc.
# Map GPT-5 variants to their config names
gpt5_mappings = {
"gpt-5-standard": {
"default": "gpt5_standard_default",
"stream": "gpt5_standard_stream",
"decomposer": "gpt5_standard_default",
"fast": "gpt5_standard_default",
},
"gpt-5-fast": {
"default": "gpt5_fast_default",
"stream": "gpt5_fast_stream",
"decomposer": "gpt5_fast_default",
"fast": "gpt5_fast_default",
},
"gpt-5.1": {
"default": "gpt5_1_default",
"stream": "gpt5_1_stream",
"decomposer": "gpt5_1_default",
"fast": "gpt5_1_default",
},
"gpt-5.2": {
"default": "gpt5_2_default",
"stream": "gpt5_2_stream",
"decomposer": "gpt5_2_default",
"fast": "gpt5_2_default",
},
}
# Map Claude models to their config names
claude_mappings = {
"claude-sonnet-4": {
"default": "claude-sonnet-4-default",
"stream": "claude-sonnet-4-stream",
"decomposer": "claude-sonnet-4-decomposer",
"fast": "claude-sonnet-4-fast",
},
"claude-sonnet-4-0": {
"default": "claude-sonnet-4-0-default",
"stream": "claude-sonnet-4-0-stream",
"decomposer": "claude-sonnet-4-0-decomposer",
"fast": "claude-sonnet-4-0-fast",
},
"claude-sonnet-4-5": {
"default": "claude-sonnet-4-5-default",
"stream": "claude-sonnet-4-5-stream",
"decomposer": "claude-sonnet-4-5-decomposer",
"fast": "claude-sonnet-4-5-fast",
},
}
# Check GPT-5 mappings first
if model_name in gpt5_mappings:
return gpt5_mappings[model_name].get(config_type)
# Check Claude mappings
if model_name in claude_mappings:
return claude_mappings[model_name].get(config_type)
# For other models, return None (will fall back to defaults)
return None
# Backward compatibility factory methods
class LLMFactory:
@classmethod
def get_default_llm(cls, model_name: Optional[str] = None) -> Any:
if model_name in _ANTHROPIC_CONFIGS.keys():
return create_anthropic_llm(_ANTHROPIC_CONFIGS[model_name])
elif model_name in _LITE_LLM_CONFIGS.keys():
return create_openai_llm(_LITE_LLM_CONFIGS[model_name])
elif model_name in _DEFAULT_CONFIGS.keys():
config = _DEFAULT_CONFIGS[model_name]
return create_openai_llm(config)
# Map user-facing model name to config name
config_name = _get_config_name_for_model(model_name, "default")
if config_name and config_name in _ANTHROPIC_CONFIGS:
return create_anthropic_llm(_ANTHROPIC_CONFIGS[config_name])
elif config_name and config_name in _LITE_LLM_CONFIGS:
return create_openai_llm(_LITE_LLM_CONFIGS[config_name])
elif config_name and config_name in _DEFAULT_CONFIGS:
return create_openai_llm(_DEFAULT_CONFIGS[config_name])
else:
return create_openai_llm(_DEFAULT_CONFIGS["default"])
@classmethod
def get_stream_llm(cls, model_name: Optional[str] = None) -> Any:
if model_name in _ANTHROPIC_CONFIGS.keys():
return create_anthropic_llm(_ANTHROPIC_CONFIGS[model_name])
elif model_name in _LITE_LLM_CONFIGS.keys():
return create_openai_llm(_LITE_LLM_CONFIGS[model_name])
elif model_name in _DEFAULT_CONFIGS.keys():
config = _DEFAULT_CONFIGS[model_name]
return create_openai_llm(config)
# Map user-facing model name to config name
config_name = _get_config_name_for_model(model_name, "stream")
if config_name and config_name in _ANTHROPIC_CONFIGS:
return create_anthropic_llm(_ANTHROPIC_CONFIGS[config_name])
elif config_name and config_name in _LITE_LLM_CONFIGS:
return create_openai_llm(_LITE_LLM_CONFIGS[config_name])
elif config_name and config_name in _DEFAULT_CONFIGS:
return create_openai_llm(_DEFAULT_CONFIGS[config_name])
else:
return create_openai_llm(_DEFAULT_CONFIGS["stream"])
@classmethod
def get_decomposer_llm(cls, model_name: Optional[str] = None) -> Any:
if model_name in _ANTHROPIC_CONFIGS.keys():
return create_anthropic_llm(_ANTHROPIC_CONFIGS[model_name])
elif model_name in _LITE_LLM_CONFIGS.keys():
return create_openai_llm(_LITE_LLM_CONFIGS[model_name])
elif model_name in _DEFAULT_CONFIGS.keys():
config = _DEFAULT_CONFIGS[model_name]
return create_openai_llm(config)
# Map user-facing model name to config name
config_name = _get_config_name_for_model(model_name, "decomposer")
if config_name and config_name in _ANTHROPIC_CONFIGS:
return create_anthropic_llm(_ANTHROPIC_CONFIGS[config_name])
elif config_name and config_name in _LITE_LLM_CONFIGS:
return create_openai_llm(_LITE_LLM_CONFIGS[config_name])
elif config_name and config_name in _DEFAULT_CONFIGS:
return create_openai_llm(_DEFAULT_CONFIGS[config_name])
else:
return create_openai_llm(_DEFAULT_CONFIGS["decomposer"])
@classmethod
def get_fast_llm(cls, streaming: bool = False, model_name: Optional[str] = None) -> Any:
if model_name in _ANTHROPIC_CONFIGS.keys():
return create_anthropic_llm(_ANTHROPIC_CONFIGS[model_name])
elif model_name in _LITE_LLM_CONFIGS.keys():
return create_openai_llm(_LITE_LLM_CONFIGS[model_name])
elif model_name in _DEFAULT_CONFIGS.keys():
config = _DEFAULT_CONFIGS[model_name]
return create_openai_llm(config)
# Map user-facing model name to config name
config_name = _get_config_name_for_model(model_name, "fast")
if config_name and config_name in _ANTHROPIC_CONFIGS:
return create_anthropic_llm(_ANTHROPIC_CONFIGS[config_name])
elif config_name and config_name in _LITE_LLM_CONFIGS:
return create_openai_llm(_LITE_LLM_CONFIGS[config_name])
elif config_name and config_name in _DEFAULT_CONFIGS:
return create_openai_llm(_DEFAULT_CONFIGS[config_name])
else:
config_key = "fast_stream" if streaming else "fast"
return create_openai_llm(_DEFAULT_CONFIGS[config_key])
@@ -26,7 +26,7 @@ from sqlalchemy import select
from sqlmodel.ext.asyncio.session import AsyncSession
from pi import logger
from pi.agents.sql_agent.tools import format_as_bullet_points
from pi.agents.sql_agent.helpers import format_as_bullet_points
from pi.app.models import Chat
from pi.app.models import Message
from pi.app.models import MessageFlowStep
+5 -2
View File
@@ -108,17 +108,20 @@ class ActionCategorySelection(BaseModel):
"states",
"modules",
"pages",
"assets",
"users",
"intake",
"members",
"activity",
"attachments",
"comments",
"links",
"properties",
"types",
"worklogs",
"initiatives",
"teamspaces",
"stickies",
"customers",
"workspaces",
"retrieval_tools",
]
rationale: Optional[str] = None
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
#
# Licensed under the Plane Commercial License (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://plane.so/legals/eula
#
# DO NOT remove or modify this notice.
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
"""Comprehensive category-by-category SDK audit"""
import sys
from typing import TypedDict
sys.path.insert(0, "/Users/hemasunderchintada/plane/plane-ee/apps/pi")
from plane import PlaneClient
from pi.services.actions.plane_sdk_adapter import PlaneSDKAdapter
class AuditResult(TypedDict):
category: str
status: str
sdk_count: int
adapter_count: int
matched: int
missing: list[str]
phantom: list[str]
client = PlaneClient(api_key="test", base_url="https://api.plane.so")
# Define all categories to check
categories = {
"projects": (client.projects, ["_project"]),
"cycles": (client.cycles, ["_cycle"]),
"modules": (client.modules, ["_module"]),
"work_items": (client.work_items, ["_work_item", "_workitem"]),
"labels": (client.labels, ["_label"]),
"states": (client.states, ["_state"]),
"pages": (client.pages, ["_page"]),
"users": (client.users, ["_user"]),
}
print("=" * 80)
print("COMPREHENSIVE CATEGORY-BY-CATEGORY SDK AUDIT")
print("=" * 80)
all_results: list[AuditResult] = []
for cat_name, (sdk_obj, adapter_patterns) in categories.items():
# Get actual SDK methods
sdk_methods = sorted([m for m in dir(sdk_obj) if not m.startswith("_") and m not in ["base_path", "config", "session"]])
# Get adapter methods
adapter_methods = []
for name in dir(PlaneSDKAdapter):
if name.startswith("_") or name == "client":
continue
# Check if method matches this category
for pattern in adapter_patterns:
if pattern in name.lower():
adapter_methods.append(name)
break
adapter_methods = sorted(set(adapter_methods))
# Map adapter methods to SDK methods
matched = []
unmatched_adapter = []
unmatched_sdk = []
for sdk_m in sdk_methods:
# Try to find matching adapter method
# Pattern: category + sdk_method -> e.g., create_project for 'create'
expected_adapter = f"{sdk_m}_{cat_name[:-1]}" if cat_name.endswith("s") else f"{sdk_m}_{cat_name}"
if expected_adapter in adapter_methods:
matched.append((sdk_m, expected_adapter))
else:
# Try other patterns
found = False
for am in adapter_methods:
if sdk_m in am or am.endswith(f"_{sdk_m}"):
matched.append((sdk_m, am))
found = True
break
if not found:
unmatched_sdk.append(sdk_m)
# Find adapter methods not matched
matched_adapter_methods = [m[1] for m in matched]
unmatched_adapter = [m for m in adapter_methods if m not in matched_adapter_methods]
# Print results
status = "" if len(sdk_methods) == len(matched) and len(unmatched_adapter) == 0 else ""
print(f"\n{status} {cat_name.upper()}")
print(f" Actual SDK: {len(sdk_methods)} methods")
print(f" Adapter: {len(adapter_methods)} methods ({len(matched)} matched)")
if unmatched_sdk:
print(" ⚠️ Missing in adapter:")
for m in unmatched_sdk:
print(f" - {m}")
if unmatched_adapter:
print(" ⚠️ Phantom in adapter:")
for m in unmatched_adapter:
print(f" - {m}")
all_results.append({
"category": cat_name,
"status": status,
"sdk_count": len(sdk_methods),
"adapter_count": len(adapter_methods),
"matched": len(matched),
"missing": unmatched_sdk,
"phantom": unmatched_adapter,
})
# Summary
print("\n" + "=" * 80)
print("SUMMARY")
print("=" * 80)
total_issues = sum(len(r["missing"]) + len(r["phantom"]) for r in all_results)
perfect = [r for r in all_results if r["status"] == ""]
print(f"\nPerfect categories: {len(perfect)}/{len(all_results)}")
print(f"Total issues found: {total_issues}")
if total_issues == 0:
print("\n✅ ✅ ✅ ALL CATEGORIES HAVE 1-1 PARITY ✅ ✅ ✅")
else:
print("\n⚠️ Categories needing attention:")
for r in all_results:
if r["status"] == "":
print(f" - {r["category"]}: {len(r["missing"])} missing, {len(r["phantom"])} phantom")
+1 -1
View File
@@ -3,7 +3,7 @@ fastapi==0.111.1
uvicorn==0.38.0
requests==2.32.5
pydantic==2.8.2
plane-sdk==0.2.0
plane-sdk==0.2.3
openai==2.8.1
html2text==2024.2.26
python-json-logger==3.3.0
@@ -705,7 +705,7 @@ class MobileAPITester:
payload["workspace_id"] = self.config.workspace_id
v1_resp, v1_err = self._make_request("POST", v1_url, json_data=payload)
# V2 uses query params
v2_params = {"is_project_chat": False}
if self.config.workspace_id:
@@ -1058,7 +1058,7 @@ class MobileAPITester:
differences_serializable = json.loads(json.dumps(str(result.differences)))
except Exception:
differences_serializable = str(result.differences)
results_data.append(
{
"endpoint_name": result.endpoint_name,