From fc187a883fc28f99ea252d37f25d149128cf2487 Mon Sep 17 00:00:00 2001 From: Akshita Goyal <36129505+gakshita@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:11:42 +0530 Subject: [PATCH] [PAI-957][PAI-875] feat: implemented ask build switcher (#4659) * feat: implemented ask build switcher * fix: cleanup * ask-build implementation in pi backend * restrict reference to UUIDs in user facing response * fix oauth flow in the build mode * fix: OAuth message was being yielded twice * add mode to chat preferences table * clean generic_query_tool * fix: stored the mode in the store * pseudo streaming of final answer * remove the no_actions_planned exit in build mode so that it can hanleretrieval only questions also * migrate to sdk v0.2.0 * add json streaming in reasoning block * clean unused tools from kit * merge preview * default mode for apps call to get-answer is build * merge changes in Pi folder in preview branch * fix reasoning header issues in ask mode * clean no-actions-planned blocks * logs for entity url tracking * trim actions prompt. clean pk args from sdk methods * fix: imlemented improved reasoning block * project feature toggle before creation in planning mode * fix nonetype error when entity urls is empty * fix: reimplemented ask build changes * fix: ticker * fix: css * fix table rendering issue in streaming api * ask mode rejecting create table request as action * remove old reasoning chunk stream to avoid duplications * change header message for tool_message reasoning chunk * remove results content from thinking block * remove search active cycle result restriction to 1 entity * add mode in rewrite. remove long reasoning instruction during tool calls loop * retain project context during rewrite * fix: implemneted chevron + fixed max height * store source in msgs table * add gpt-5.1 support * fix rewrite failure due to chatrequest reconstruction error * fix vector search result display in reasoning * fix: reasoning header width fixed * fix place holder resolution in execute-action * fix project_id placeholder issue * fix worklog sdk adapter bugs * aggregate token counting for each step type * clean action_planner.py code * upgrade openai and langchain libs * cleaning workitem tools * fix: css * fix: css + updated openai version * clean tool args handling for workitems, projects, cycles and modules * clean batch execution code * feat: update batch execution and build mode helpers * update links attachments and workitem types and properties tools * beautify reasoning headers * update issue_id to workitem_id in worklogs tools * add pydantic models for types, comments and intake * clean tool execution steps * feat: restore apps/pi with latest changes after selective reset - Restored all apps/pi changes including batch execution cleanup - Deleted batch_action_orchestrator, batch_execution, and batch_execution_helpers - Tool refinements and execution helpers updates - SDK adapter improvements - Reset other folders to cc5c927ba8 to avoid merge conflicts * PAI - Fix the tool definitions of get-members api methods * PAI fix still working msg formatting for mobile endpoint * fix: build * clean batch_execution code * fix: refactor * updade actions api/v2 endpoint/ * deduplicate tool names before sending to llm * fix json parsing for claude models * changes to action execution logic - exclude LLM intelligence * clean build mode prompts * add delete workitem tool --------- Co-authored-by: sunder Co-authored-by: sunder <154698110+sunder-ch@users.noreply.github.com> --- apps/pi/docker-compose.yml | 10 - apps/pi/pi/agents/sql_agent/base.py | 158 +- apps/pi/pi/agents/sql_agent/prompts.py | 68 +- apps/pi/pi/agents/sql_agent/tools.py | 18 +- .../74852ee169e1_polymorphic_focus_context.py | 76 + apps/pi/pi/app/api/v1/endpoints/_sse.py | 5 +- apps/pi/pi/app/api/v1/endpoints/chat.py | 201 +- .../pi/pi/app/api/v1/endpoints/mobile/chat.py | 336 +-- .../api/v1/helpers/batch_execution_helpers.py | 615 ---- apps/pi/pi/app/api/v1/helpers/execution.py | 29 + .../app/api/v1/helpers/plane_sql_queries.py | 63 +- apps/pi/pi/app/api/v2/endpoints/actions.py | 226 +- apps/pi/pi/app/api/v2/endpoints/responses.py | 54 +- .../api/v2/helpers/batch_execution_helpers.py | 624 ----- apps/pi/pi/app/models/chat.py | 15 + apps/pi/pi/app/models/enums.py | 19 + apps/pi/pi/app/models/message.py | 3 + apps/pi/pi/app/schemas/chat.py | 10 + apps/pi/pi/config.py | 14 +- .../pi/services/actions/category_selector.py | 1 - .../pi/services/actions/plane_sdk_adapter.py | 2483 +++++++++-------- .../pi/pi/services/actions/prompt_followup.py | 54 +- apps/pi/pi/services/actions/registry.py | 26 +- apps/pi/pi/services/actions/tools/__init__.py | 18 + apps/pi/pi/services/actions/tools/activity.py | 14 +- apps/pi/pi/services/actions/tools/assets.py | 40 +- .../pi/services/actions/tools/attachments.py | 118 +- apps/pi/pi/services/actions/tools/base.py | 94 +- apps/pi/pi/services/actions/tools/comments.py | 89 +- apps/pi/pi/services/actions/tools/cycles.py | 114 +- .../services/actions/tools/entity_search.py | 169 +- apps/pi/pi/services/actions/tools/intake.py | 30 +- apps/pi/pi/services/actions/tools/labels.py | 80 +- apps/pi/pi/services/actions/tools/links.py | 77 +- apps/pi/pi/services/actions/tools/members.py | 19 +- apps/pi/pi/services/actions/tools/modules.py | 84 +- apps/pi/pi/services/actions/tools/pages.py | 42 +- apps/pi/pi/services/actions/tools/projects.py | 136 +- .../pi/services/actions/tools/properties.py | 223 +- apps/pi/pi/services/actions/tools/states.py | 79 +- apps/pi/pi/services/actions/tools/types.py | 89 +- apps/pi/pi/services/actions/tools/users.py | 17 +- .../pi/pi/services/actions/tools/workitems.py | 287 +- apps/pi/pi/services/actions/tools/worklogs.py | 67 +- apps/pi/pi/services/chat/action_executor.py | 199 ++ apps/pi/pi/services/chat/action_planner.py | 872 ++++++ .../pi/services/chat/askmode_tool_executor.py | 459 +++ apps/pi/pi/services/chat/chat.py | 1048 ++----- .../chat/helpers/action_execution_helpers.py | 421 +++ .../services/chat/helpers/action_executor.py | 1876 ------------- .../chat/helpers/action_property_mapper.py | 588 ++++ .../chat/helpers/action_summary_generator.py | 1417 ---------- .../services/chat/helpers/agent_executor.py | 791 ------ .../services/chat/helpers/agent_selector.py | 187 -- .../services/chat/helpers/ask_mode_helpers.py | 739 +++++ .../chat/helpers/batch_action_orchestrator.py | 764 ----- .../chat/helpers/batch_execution_context.py | 192 -- .../chat/helpers/batch_execution_helpers.py | 414 --- .../chat/helpers/build_mode_helpers.py | 1360 +++++++++ .../services/chat/helpers/context_manager.py | 14 + .../pi/services/chat/helpers/flow_tracking.py | 784 ++++++ .../chat/helpers/placeholder_orchestrator.py | 546 ++++ .../chat/helpers/response_processor.py | 3 +- .../pi/pi/services/chat/helpers/tool_utils.py | 773 ++++- apps/pi/pi/services/chat/kit.py | 468 +--- apps/pi/pi/services/chat/prompts.py | 650 ++--- apps/pi/pi/services/chat/templates.py | 66 +- apps/pi/pi/services/chat/utils.py | 114 +- apps/pi/pi/services/dupes/dupes.py | 6 +- apps/pi/pi/services/llm/llms.py | 58 +- apps/pi/pi/services/retrievers/docs_search.py | 4 +- .../pi/pi/services/retrievers/issue_search.py | 6 +- .../pi/pi/services/retrievers/pages_search.py | 7 +- .../retrievers/pg_store/action_artifact.py | 73 +- .../pi/services/retrievers/pg_store/chat.py | 490 +++- .../services/retrievers/pg_store/message.py | 39 +- apps/pi/pi/services/schemas/chat.py | 53 +- .../pi/tests/example_slack_answer_request.py | 145 + .../services/test_cycles_add_work_items.py | 74 + .../tests/services/test_plane_api_methods.py | 796 ------ .../services/test_pydantic_compatibility.py | 308 -- apps/pi/pi/tests/verify_lazy_llm.py | 79 + apps/pi/requirements.txt | 10 +- apps/pi/tests/test_v1_v2_comparison.py | 4 +- .../pi-chat/conversation/ai-message.tsx | 6 +- .../pi-chat/conversation/feedback.tsx | 2 +- .../pi-chat/conversation/reasoning.tsx | 20 +- .../pi-chat/converse/voice-input.tsx | 19 +- .../components/pi-chat/input/dnd-wrapper.tsx | 6 +- .../components/pi-chat/input/focus-filter.tsx | 8 +- apps/web/ee/components/pi-chat/input/mode.tsx | 61 + apps/web/ee/components/pi-chat/input/root.tsx | 52 +- .../ee/components/pi-chat/system-prompts.tsx | 6 +- apps/web/ee/store/pi-chat/pi-chat.ts | 43 +- apps/web/ee/types/pi-chat.d.ts | 3 + packages/propel/src/icons/ai-search.tsx | 22 + packages/propel/src/icons/ai-write.tsx | 23 + packages/propel/src/icons/index.ts | 2 + 98 files changed, 11736 insertions(+), 12428 deletions(-) create mode 100644 apps/pi/pi/alembic/versions/74852ee169e1_polymorphic_focus_context.py delete mode 100644 apps/pi/pi/app/api/v1/helpers/batch_execution_helpers.py create mode 100644 apps/pi/pi/app/api/v1/helpers/execution.py delete mode 100644 apps/pi/pi/app/api/v2/helpers/batch_execution_helpers.py create mode 100644 apps/pi/pi/services/chat/action_executor.py create mode 100644 apps/pi/pi/services/chat/action_planner.py create mode 100644 apps/pi/pi/services/chat/askmode_tool_executor.py create mode 100644 apps/pi/pi/services/chat/helpers/action_execution_helpers.py delete mode 100644 apps/pi/pi/services/chat/helpers/action_executor.py create mode 100644 apps/pi/pi/services/chat/helpers/action_property_mapper.py delete mode 100644 apps/pi/pi/services/chat/helpers/action_summary_generator.py delete mode 100644 apps/pi/pi/services/chat/helpers/agent_executor.py delete mode 100644 apps/pi/pi/services/chat/helpers/agent_selector.py create mode 100644 apps/pi/pi/services/chat/helpers/ask_mode_helpers.py delete mode 100644 apps/pi/pi/services/chat/helpers/batch_action_orchestrator.py delete mode 100644 apps/pi/pi/services/chat/helpers/batch_execution_context.py delete mode 100644 apps/pi/pi/services/chat/helpers/batch_execution_helpers.py create mode 100644 apps/pi/pi/services/chat/helpers/build_mode_helpers.py create mode 100644 apps/pi/pi/services/chat/helpers/flow_tracking.py create mode 100644 apps/pi/pi/services/chat/helpers/placeholder_orchestrator.py create mode 100644 apps/pi/pi/tests/example_slack_answer_request.py create mode 100644 apps/pi/pi/tests/services/test_cycles_add_work_items.py delete mode 100644 apps/pi/pi/tests/services/test_plane_api_methods.py delete mode 100644 apps/pi/pi/tests/services/test_pydantic_compatibility.py create mode 100644 apps/pi/pi/tests/verify_lazy_llm.py create mode 100644 apps/web/ee/components/pi-chat/input/mode.tsx create mode 100644 packages/propel/src/icons/ai-search.tsx create mode 100644 packages/propel/src/icons/ai-write.tsx diff --git a/apps/pi/docker-compose.yml b/apps/pi/docker-compose.yml index 8af91a61c2..2c6aa77acd 100644 --- a/apps/pi/docker-compose.yml +++ b/apps/pi/docker-compose.yml @@ -61,15 +61,6 @@ x-app-env: &app-env AWS_S3_REGION: ${AWS_S3_REGION} DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY} -x-feeder-env: &feeder-env - DEV_WORKSPACE_ID: ${DEV_WORKSPACE_ID} - FEED_ISSUES_DATA: ${FEED_ISSUES_DATA} - FEED_PAGES_DATA: ${FEED_PAGES_DATA} - FEED_DOCS_DATA: ${FEED_DOCS_DATA} - FEED_SLICES: ${FEED_SLICES} - BATCH_SIZE: ${BATCH_SIZE} - SCROLL_TIMEOUT: ${SCROLL_TIMEOUT} - x-celery-env: &celery-env CELERY_BROKER_URL: ${CELERY_BROKER_URL:-pyamqp://${RABBITMQ_USER:-admin}:${RABBITMQ_PASSWORD:-admin123}@rabbitmq:5672//} CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-rpc://} @@ -79,7 +70,6 @@ x-celery-env: &celery-env CELERY_VECTOR_SYNC_RETRY_DELAY: ${CELERY_VECTOR_SYNC_RETRY_DELAY:-60} CELERY_WORKSPACE_PLAN_SYNC_ENABLED: ${CELERY_WORKSPACE_PLAN_SYNC_ENABLED:-1} CELERY_WORKSPACE_PLAN_SYNC_INTERVAL: ${CELERY_WORKSPACE_PLAN_SYNC_INTERVAL:-86400} - DEV_WORKSPACE_ID: ${DEV_WORKSPACE_ID} PLANE_PI_INTERNAL_API_URL: ${PLANE_PI_INTERNAL_API_URL} PLANE_PI_INTERNAL_API_SECRET: ${PLANE_PI_INTERNAL_API_SECRET} PLANE_PI_DATABASE_URL: ${PLANE_PI_DATABASE_URL} diff --git a/apps/pi/pi/agents/sql_agent/base.py b/apps/pi/pi/agents/sql_agent/base.py index 03015ff39d..ee3689aa08 100644 --- a/apps/pi/pi/agents/sql_agent/base.py +++ b/apps/pi/pi/agents/sql_agent/base.py @@ -1,4 +1,5 @@ import json +import re import time from importlib.resources import read_text from typing import Any @@ -8,9 +9,10 @@ from typing import Union from typing import cast from uuid import UUID -from langchain.schema import AIMessage -from langchain.schema import HumanMessage -from langchain.schema import SystemMessage +from langchain_core.messages import AIMessage +from langchain_core.messages import HumanMessage +from langchain_core.messages import SystemMessage +from langchain_core.utils.json import parse_json_markdown from pydantic import BaseModel from rich.console import Console from rich.panel import Panel @@ -20,8 +22,10 @@ from sqlglot import parse_one from sqlmodel.ext.asyncio.session import AsyncSession from pi import logger +from pi.app.models.enums import FlowStepType from pi.app.models.enums import MessageMetaStepType from pi.config import settings +from pi.services.chat.helpers.flow_tracking import FlowStepCollector 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 @@ -129,7 +133,8 @@ async def _perform_table_selection_llm_call( Note: For GPT-5 table selection, automatically uses gpt-5-fast instead of gpt-5-standard to prevent token limit issues with large schema context. SQL generation keeps original model. """ - # Create structured model dynamically and set tracking context + + # Create model instance dynamically and set tracking context # Use the provided model or fall back to the global one if llm_model: # For GPT-5 table selection, automatically reduce to fast variant to prevent token limits @@ -143,9 +148,27 @@ async def _perform_table_selection_llm_call( else: table_selection_model_instance = table_selection_model - structured_table_selection_model = table_selection_model_instance.with_structured_output(TableSelectionResponse, include_raw=True) # type: ignore[arg-type] - structured_table_selection_model.set_tracking_context(message_id, db, MessageMetaStepType.SQL_TABLE_SELECTION, chat_id=chat_id) # type: ignore[attr-defined] - return await structured_table_selection_model.ainvoke(langchain_messages) + # Set tracking context on the model + table_selection_model_instance.set_tracking_context(message_id, db, MessageMetaStepType.SQL_TABLE_SELECTION, chat_id=chat_id) # type: ignore[attr-defined] + + # Get raw response from LLM + raw_response = await table_selection_model_instance.ainvoke(langchain_messages) + + # Extract content and parse JSON (handles markdown-wrapped JSON from Claude) + content = raw_response.content if hasattr(raw_response, "content") else str(raw_response) + + try: + # parse_json_markdown handles both raw JSON and markdown-wrapped JSON + parsed_data = parse_json_markdown(content) + # Validate against Pydantic model + parsed_obj = TableSelectionResponse.model_validate(parsed_data) + return {"parsed": parsed_obj, "raw": raw_response} + except Exception as e: + log.error(f"ChatID: {chat_id} - Failed to parse table selection response: {e}") + log.error(f"ChatID: {chat_id} - Raw content: {content}") + # Fallback to empty table list + parsed_obj = TableSelectionResponse(relevant_tables=[]) + return {"parsed": parsed_obj, "raw": raw_response} # Function to select relevant tables for SQL query generation @@ -258,11 +281,12 @@ async def sql_generation( llm_model: Model to use for SQL generation Returns: - Generated SQL query as string + Generated SQL query as string (with markdown wrappers stripped) Note: For GPT-5, table selection uses gpt-5-fast to prevent token limits, but SQL generation uses the original model for maximum quality. """ + # Prepare messages for the LLM langchain_messages: List[Message] = [SystemMessage(content=modified_sql_generator)] for msg in messages: @@ -277,12 +301,25 @@ async def sql_generation( log.error("SQL generation failed after all retries") return "SELECT 'Error: Unable to generate SQL query due to processing limitations' as error_message;" - # Ensure we always return a string - fixed type handling + # Extract content if hasattr(response, "content"): - return str(response.content) + raw_sql = str(response.content) else: - # Handle any other case by converting to string - return str(response) + raw_sql = str(response) + + # Strip markdown code blocks (```sql...``` or ```...```) + # Use regex to detect if content is wrapped in code blocks + pattern = r"^```(?:sql)?\s*\n(.*?)\n```\s*$" + match = re.match(pattern, raw_sql.strip(), re.DOTALL) + + if match: + # Extract just the SQL from markdown wrapper + clean_sql = match.group(1).strip() + else: + # No markdown wrapper, return as-is + clean_sql = raw_sql.strip() + + return clean_sql # SQL generation function @@ -306,6 +343,14 @@ async def text2sql( attachment_blocks: list[dict[str, Any]] | None = None, ) -> tuple[Dict[str, Any], Dict[str, Any]]: try: + # Use caller-provided step_order from query_flow_store; no DB lookup + base_step_raw = query_flow_store.get("step_order") + try: + base_step = int(base_step_raw) if base_step_raw is not None else 0 + except Exception: + base_step = 0 + log.info(f"ChatID: {chat_id} - text2sql base_step from flow_store: {base_step}") + # Initialize a single dictionary to collect all intermediate results intermediate_results: Dict[str, Any] = { "steps": [], # Initialize steps as an empty list to avoid KeyError later @@ -313,6 +358,9 @@ async def text2sql( "entity_urls": None, } + # Initialize flow step collector for text2sql sub-steps + flow_collector = FlowStepCollector(query_id=message_id, chat_id=str(chat_id) if chat_id else "", db=db) if message_id and chat_id else None + # Create user message for both preset and regular flows user_message = _create_message_with_attachments(query, attachment_blocks) @@ -433,6 +481,21 @@ async def text2sql( # Store table validation step in our intermediate results intermediate_results["post_processed_relevant_tables"] = relevant_tables + # Add table selection step to collector + if flow_collector: + flow_collector.add_step( + step_order=base_step + 1, + step_type=FlowStepType.TOOL, + tool_name="structured_db_tool_table_selection", + content={"relevant_tables": relevant_tables}, + execution_data={ + "relevant_tables": intermediate_results.get("relevant_tables"), + "post_processed_relevant_tables": relevant_tables, + }, + is_planned=False, + is_executed=True, + ) + # Step 2: Fetching whole schema for all the relevant tables try: relevant_tables_schemas = get_table_schemas(relevant_tables) @@ -518,6 +581,22 @@ async def text2sql( # Store SQL generation in our intermediate results intermediate_results["generated_sql"] = generated_query + + # Add preset SQL generation step to collector + if flow_collector: + flow_collector.add_step( + step_order=base_step + 2, + step_type=FlowStepType.TOOL, + tool_name="structured_db_tool_sql_generation", + content={"generated_sql": generated_query[:500]}, + execution_data={ + "generated_sql": generated_query, + "is_preset": True, + "relevant_tables": intermediate_results.get("post_processed_relevant_tables"), + }, + is_planned=False, + is_executed=True, + ) else: # Regular SQL generation with LLM try: @@ -570,6 +649,21 @@ async def text2sql( # Store SQL generation in our intermediate results intermediate_results["generated_sql"] = generated_query + # Add SQL generation step to collector + if flow_collector: + flow_collector.add_step( + step_order=base_step + 2, + step_type=FlowStepType.TOOL, + tool_name="structured_db_tool_sql_generation", + content={"generated_sql": generated_query[:500]}, + execution_data={ + "generated_sql": generated_query, + "relevant_tables": intermediate_results.get("post_processed_relevant_tables"), + }, + is_planned=False, + is_executed=True, + ) + except Exception as e: log.error(f"Error during SQL generation for chat ID {chat_id}: {e}") intermediate_results["sql_generation_error"] = e @@ -602,8 +696,8 @@ async def text2sql( generated_query_tables = set() # Extract tables from all SELECT statements in the query - for select in parsed_query.find_all(exp.Select): - select_tables = _get_available_tables(select) + for select_node in parsed_query.find_all(exp.Select): + select_tables = _get_available_tables(select_node) generated_query_tables.update(select_tables) # Find extra tables that need to be added to CTE @@ -633,6 +727,21 @@ async def text2sql( # Store CTE generation in our intermediate results intermediate_results["cte_head"] = CTE_head + # Add CTE generation step to collector + if flow_collector: + flow_collector.add_step( + step_order=base_step + 3, + step_type=FlowStepType.TOOL, + tool_name="structured_db_tool_cte_generation", + content={"cte_head": CTE_head[:500]}, + execution_data={ + "cte_head": CTE_head, + "tables_for_cte": tables_for_cte, + }, + is_planned=False, + is_executed=True, + ) + except Exception as e: log.error(f"Error generating CTE query for chat ID {chat_id}: {e}") intermediate_results["cte_generation_error"] = e @@ -658,6 +767,22 @@ async def text2sql( intermediate_results["final_query"] = final_query + # Add final query step to collector + if flow_collector: + flow_collector.add_step( + step_order=base_step + 4, + step_type=FlowStepType.TOOL, + tool_name="structured_db_tool_final_query", + content={"final_query": final_query[:500]}, + execution_data={ + "final_query": final_query, + "generated_sql": intermediate_results.get("generated_sql"), + "cte_head": intermediate_results.get("cte_head"), + }, + is_planned=False, + is_executed=True, + ) + # Step 6: SQL Execution query_execution_result: Any try: @@ -757,6 +882,11 @@ async def text2sql( response_data: Dict[str, Any] = {"sql_query": final_query, "results": query_execution_result} + # Persist all collected flow steps + if flow_collector: + async with flow_collector: + pass # Steps are persisted in __aexit__ + return intermediate_results, response_data except Exception as e: diff --git a/apps/pi/pi/agents/sql_agent/prompts.py b/apps/pi/pi/agents/sql_agent/prompts.py index 788d9804f7..dc0aa806c2 100644 --- a/apps/pi/pi/agents/sql_agent/prompts.py +++ b/apps/pi/pi/agents/sql_agent/prompts.py @@ -168,57 +168,37 @@ Here is the list of available tables in Plane's database with their descriptions {table_descriptions} -Your goal is to select only the tables that are directly relevant to fulfilling this query. Follow these steps: +Your goal is to select only the tables that are directly relevant to fulfilling this query. -1. Analyze the user's query to identify key elements and requirements. -2. Review each table in the table_descriptions, considering: - - The table's purpose and description - - What the table contains and does not contain - - The table's relationships with other tables -3. Categorize each table as "Highly Relevant", "Possibly Relevant", or "Not Relevant" to the query. -4. Select the tables that are essential for answering the query. -5. If a table contains the data directly related to the query, classify it as 'Highly Relevant' immediately. -6. Format your final response as a JSON object with a "relevant_tables" key containing an array of selected table names. +**Selection Process:** +1. Identify key entities and concepts mentioned in the user query (e.g., issues, users, projects, cycles, states). +2. For each identified concept, find the corresponding table(s) that contain that data. +3. Include related tables needed for joins or filtering. +4. Include related tables needed to display human-readable names (e.g., if selecting `issue_assignees`, also include `users` table). -Before providing your final answer, wrap your analysis in tags. In this analysis: -1. Summarize the key elements from the user query. -2. For each table in the table_descriptions: - a. Briefly describe its purpose - b. Explain its relevance (or lack thereof) to the query - c. Categorize it as "Highly Relevant", "Possibly Relevant", or "Not Relevant" -3. *Important Note* - Do not ignore tables whose descriptions directly match the query, even if they have relationships with other tables. - For example, for queries about "users," always consider the users table as Highly Relevant. -4. Justify your final selections based on this categorization +**Incomplete/Active Work:** +- When the user's intent implies items that should be worked on NOW (e.g., "to add to the + current cycle", "missed to add", "should be in this cycle", "to pick up", "pending", + "open", "to-do"), and the user has not explicitly asked for completed/closed items, + you MUST include the states table in the selection. -It's OK for this section to be quite long. - -Example of the expected output format: -{{"relevant_tables": ["table1", "table2", "table3"]}} - -Additional Instructions Related to Issue Ownership: -- Ownership of an issue lies with the users assigned to it. Not with the ones who created it. -- When determining ownership of an issue, always use the issue_assignees table as the primary source. Treat it as the most relevant table for any query about who an issue belongs to, such as ownership, assignments, or responsibility. Do not prioritize issues table alone for such cases. +**Issue Ownership:** +- Ownership of an issue lies with the users assigned to it, not with the ones who created it. +- When determining ownership, always include BOTH `issue_assignees` AND `users` tables. - Phrases containing "my/mine" related to issues: - - "My issues" = Issues where the user is an assignee. Use issue_assignees for this. - - "My backlog" = Issues in backlog state where user is an assignee. Use issue_assignees for this. - - "Created by me" = Issues where created_by_id matches the user. Use issues table for this. - - When both creation and ownership appear ("my issues that I created"), use BOTH issue_assignees and issues. + - "My issues" → include `issue_assignees`, `users`, `states` tables + - "My backlog" → include `issue_assignees`, `users`, `states` tables + - "Created by me" → include `issues`, `users`, `states` tables + - "Issues assigned to [person]" → include `issue_assignees`, `users`, `states` tables -Additional Instructions Related to Incomplete/Active Work: -- For queries that imply items that should be worked on (e.g., "to add to the current cycle", "missed to add", "to pick up", "open", "pending", "not done"), prioritize uncompleted states. -- Treat the states table as Highly Relevant and include it in selections when the query does not specify states but implies active/to-do/pending items. -- Default uncompleted buckets/groups: backlog, unstarted, started. Exclude completed/cancelled unless explicitly requested by the user. +**Priority Queries:** +- For priority-related queries, include the `issues` table (which contains the priority attribute). -Additional Instructions Related to Priority Canonicalization: -- Canonical priority values are: urgent, high, medium, low (case-insensitive). No other priority names should be assumed. -- Map common synonyms to canonical values for interpretation: "highest"/"critical"/"blocker"/"p0" → urgent; "very high"/"p1" → high; "normal"/"standard" → medium; "lowest" → low. -- Consider the priority attribute on the issues table the canonical source when selecting tables for priority-driven queries. - -Important requirements: +**Output Format:** +- Your response must be ONLY a valid JSON object with this exact format: +{{"relevant_tables": ["table1", "table2", "table3"]}} - Include ONLY tables that exist in the provided table_descriptions. - Select only tables that are directly relevant to the query or necessary due to relationships. +- Do not include any additional text, explanations, or analysis outside the JSON object. - Do not generate SQL queries or any other extraneous information. -- Ensure your final output is a valid JSON object. -- Do not include any additional text or explanations outside the JSON object. """ diff --git a/apps/pi/pi/agents/sql_agent/tools.py b/apps/pi/pi/agents/sql_agent/tools.py index 82560e90af..8f9cc29da3 100644 --- a/apps/pi/pi/agents/sql_agent/tools.py +++ b/apps/pi/pi/agents/sql_agent/tools.py @@ -680,7 +680,7 @@ def extract_ids_from_sql_result(query_result: Any) -> Dict[str, List[str]]: async def get_refs_from_chat(db: AsyncSession, chat_id: str) -> List[str]: """Safely extract any available entity URLs from execution_data or content.""" - tool_results = await get_tool_results_from_chat_history(db, uuid.UUID(chat_id), "PLANE_STRUCTURED_DATABASE_AGENT") + tool_results = await get_tool_results_from_chat_history(db, uuid.UUID(chat_id), "structured_db_tool") extracted_entities: List[str] = [] @@ -716,7 +716,7 @@ async def get_refs_from_chat(db: AsyncSession, chat_id: str) -> List[str]: async def construct_entity_urls_vectordb(entity_ids: Dict[str, List[str]], api_base_url: str) -> List[Dict[str, str]]: """Construct entity links using OpenSearch indices for better efficiency""" entity_links: List[Dict[str, str]] = [] - + log.info(f"construct_entity_urls_vectordb: Entity IDs: {entity_ids}") if not entity_ids or not any(entity_ids.values()): return entity_links @@ -743,7 +743,7 @@ async def construct_entity_urls_vectordb(entity_ids: Dict[str, List[str]], api_b try: # Build batch query to get all documents by IDs query = {"query": {"terms": {"id": entity_list}}, "size": len(entity_list)} - + log.info(f"construct_entity_urls_vectordb: Query: {query}") # Execute search response = await vectorstore.async_search(index=index_name, body=query) @@ -751,11 +751,11 @@ async def construct_entity_urls_vectordb(entity_ids: Dict[str, List[str]], api_b for hit in response["hits"]["hits"]: source = hit["_source"] entity_id = source.get("id", "") - + log.info(f"construct_entity_urls_vectordb: Source: {source}") # Extract common fields name = source.get("name", "") workspace_slug = source.get("workspace_slug", "") - + log.info(f"construct_entity_urls_vectordb: Name: {name}, Workspace Slug: {workspace_slug}") # Construct URLs based on entity type if entity_type == "issues": # For issues: /workspace_slug/browse/PROJECT_IDENTIFIER-SEQUENCE_ID/ @@ -766,7 +766,7 @@ async def construct_entity_urls_vectordb(entity_ids: Dict[str, List[str]], api_b url = f"{api_base_url}/{workspace_slug}/browse/{issue_identifier}/" entity_link = {"name": name, "id": entity_id, "issue_identifier": issue_identifier, "url": url, "type": "issue"} entity_links.append(entity_link) - + log.info(f"construct_entity_urls_vectordb: Entity (workitem) Link: {entity_link}") elif entity_type == "pages": # For pages: global vs project pages is_global = source.get("is_global", False) @@ -784,13 +784,13 @@ async def construct_entity_urls_vectordb(entity_ids: Dict[str, List[str]], api_b entity_link = {"name": name, "id": entity_id, "url": url, "type": "page"} entity_links.append(entity_link) - + log.info(f"construct_entity_urls_vectordb: Entity (page) Link: {entity_link}") elif entity_type == "projects": # For projects: /workspace_slug/projects/project_id/overview/ url = f"{api_base_url}/{workspace_slug}/projects/{entity_id}/overview/" entity_link = {"name": name, "id": entity_id, "url": url, "type": "project"} entity_links.append(entity_link) - + log.info(f"construct_entity_urls_vectordb: Entity (project) Link: {entity_link}") elif entity_type in ["modules", "cycles"]: # For modules and cycles: /workspace_slug/projects/project_id/modules|cycles/entity_id/ project_id = source.get("project_id", "") @@ -804,7 +804,7 @@ async def construct_entity_urls_vectordb(entity_ids: Dict[str, List[str]], api_b "type": entity_type[:-1], # Remove 's' from end (modules -> module) } entity_links.append(entity_link) - + log.info(f"construct_entity_urls_vectordb: Entity (module or cycle) Link: {entity_link}") except Exception as e: log.error(f"Error constructing URLs for {entity_type}: {e}") continue diff --git a/apps/pi/pi/alembic/versions/74852ee169e1_polymorphic_focus_context.py b/apps/pi/pi/alembic/versions/74852ee169e1_polymorphic_focus_context.py new file mode 100644 index 0000000000..825840d84b --- /dev/null +++ b/apps/pi/pi/alembic/versions/74852ee169e1_polymorphic_focus_context.py @@ -0,0 +1,76 @@ +"""polymorphic focus context + +Revision ID: 74852ee169e1 +Revises: 6082d91ecc4e +Create Date: 2025-01-28 + +This migration adds polymorphic focus context columns (focus_entity_type, focus_entity_id) +to replace the specific focus_project_id and focus_workspace_id columns. +This allows supporting multiple entity types (workspace, project, cycle, module, etc.) +without adding new columns for each type. +Also adds mode column to store chat mode (ask/build). +Also adds source column to messages table to track the source of chat requests. +""" + +from typing import Sequence +from typing import Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "74852ee169e1" +down_revision: Union[str, None] = "6082d91ecc4e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add new polymorphic columns + op.add_column("user_chat_preferences", sa.Column("focus_entity_type", sa.String(length=50), nullable=True)) + op.add_column("user_chat_preferences", sa.Column("focus_entity_id", sa.Uuid(), nullable=True)) + + # Add mode column with default value 'ask' + op.add_column("user_chat_preferences", sa.Column("mode", sa.String(length=10), nullable=False, server_default="ask")) + + # Add source column to messages table to track the source of chat requests + op.add_column("messages", sa.Column("source", sa.String(length=50), nullable=True)) + + # Migrate existing data from old columns to new polymorphic structure + # If focus_project_id is set, migrate to focus_entity_type='project' and focus_entity_id=focus_project_id + op.execute(""" + UPDATE user_chat_preferences + SET focus_entity_type = 'project', focus_entity_id = focus_project_id + WHERE focus_project_id IS NOT NULL + """) + + # If focus_workspace_id is set (and focus_project_id is NULL), migrate to focus_entity_type='workspace' and focus_entity_id=focus_workspace_id + op.execute(""" + UPDATE user_chat_preferences + SET focus_entity_type = 'workspace', focus_entity_id = focus_workspace_id + WHERE focus_workspace_id IS NOT NULL AND focus_project_id IS NULL + """) + + # Note: We keep the old columns (focus_project_id, focus_workspace_id) for backward compatibility + # They will be dropped in a future migration after all code is updated + + +def downgrade() -> None: + # Migrate data back from new columns to old columns + op.execute(""" + UPDATE user_chat_preferences + SET focus_project_id = focus_entity_id + WHERE focus_entity_type = 'project' AND focus_entity_id IS NOT NULL + """) + + op.execute(""" + UPDATE user_chat_preferences + SET focus_workspace_id = focus_entity_id + WHERE focus_entity_type = 'workspace' AND focus_entity_id IS NOT NULL + """) + + # Drop new columns + op.drop_column("messages", "source") + op.drop_column("user_chat_preferences", "mode") + op.drop_column("user_chat_preferences", "focus_entity_id") + op.drop_column("user_chat_preferences", "focus_entity_type") diff --git a/apps/pi/pi/app/api/v1/endpoints/_sse.py b/apps/pi/pi/app/api/v1/endpoints/_sse.py index c2f8f2688b..959c14f817 100644 --- a/apps/pi/pi/app/api/v1/endpoints/_sse.py +++ b/apps/pi/pi/app/api/v1/endpoints/_sse.py @@ -17,7 +17,7 @@ def sse_done() -> str: return sse_event("done", {"done": True}) -def normalize_error_chunk(chunk: str) -> Optional[str]: +def normalize_error_chunk(chunk: Any) -> Optional[str]: """Convert plain text error chunks to a proper SSE error event. Recognizes patterns like "error: " (with optional quotes) and @@ -25,6 +25,9 @@ def normalize_error_chunk(chunk: str) -> Optional[str]: Returns a formatted SSE error event string or None if not applicable. """ + if not isinstance(chunk, str): + return None + if chunk.startswith("error:"): msg = chunk.split("error:", 1)[1].strip() if (msg.startswith("'") and msg.endswith("'")) or (msg.startswith('"') and msg.endswith('"')): diff --git a/apps/pi/pi/app/api/v1/endpoints/chat.py b/apps/pi/pi/app/api/v1/endpoints/chat.py index e2af7573c9..1488b65b16 100644 --- a/apps/pi/pi/app/api/v1/endpoints/chat.py +++ b/apps/pi/pi/app/api/v1/endpoints/chat.py @@ -4,7 +4,9 @@ import json from typing import Any from typing import AsyncGenerator from typing import Coroutine +from typing import Dict from typing import Optional +from typing import Union from typing import cast from urllib.parse import urlparse from uuid import UUID @@ -28,11 +30,7 @@ from pi.app.api.v1.dependencies import validate_plane_token from pi.app.api.v1.endpoints._sse import normalize_error_chunk from pi.app.api.v1.endpoints._sse import sse_done from pi.app.api.v1.endpoints._sse import sse_event -from pi.app.api.v1.helpers.batch_execution_helpers import execute_batch_actions -from pi.app.api.v1.helpers.batch_execution_helpers import format_execution_response -from pi.app.api.v1.helpers.batch_execution_helpers import prepare_execution_data -from pi.app.api.v1.helpers.batch_execution_helpers import update_assistant_message_with_execution_results -from pi.app.api.v1.helpers.batch_execution_helpers import validate_session_and_get_user +from pi.app.api.v1.helpers.execution import chosen_llm from pi.app.api.v1.helpers.plane_sql_queries import resolve_workspace_id_from_project_id from pi.app.models.enums import FlowStepType from pi.app.models.enums import UserTypeChoices @@ -61,9 +59,8 @@ from pi.app.utils.exceptions import SQLGenerationError from pi.core.db.plane_pi.lifecycle import get_async_session from pi.core.db.plane_pi.lifecycle import get_streaming_db_session from pi.services.actions.oauth_service import PlaneOAuthService +from pi.services.chat.action_executor import BuildModeToolExecutor from pi.services.chat.chat import PlaneChatBot -from pi.services.chat.helpers.batch_execution_helpers import get_original_user_query -from pi.services.chat.helpers.batch_execution_helpers import get_planned_actions_for_execution from pi.services.chat.helpers.tool_utils import format_clarification_as_text from pi.services.chat.search import ChatSearchService from pi.services.chat.templates import tiles_factory @@ -183,17 +180,19 @@ async def get_answer_for_slack(data: ChatRequest, request: Request, db: AsyncSes final_response = "" actions_data = {} clarification_data = {} - formatted_context = {} + formatted_context: dict[str, Any] = {} response_type = "response" # listen to all the stream chunks, join the chunks and return the complete response # as json object async with get_streaming_db_session() as stream_db: - base_iter = chatbot.process_query_stream(data, db=stream_db) + data.mode = "build" # type: ignore + base_iter = chatbot.process_chat_stream(data, db=stream_db) async for chunk in base_iter: - # Ignore all intermediate chunks - if chunk.startswith("πspecial reasoning block"): + if isinstance(chunk, dict): + # Currently only reasoning chunk is sent as dict. continue + # Ignore all intermediate chunks if chunk.startswith("πspecial actions blockπ: "): response_type = "actions" actions_data = json.loads(chunk.replace("πspecial actions blockπ: ", "")) @@ -232,7 +231,9 @@ async def get_answer_for_slack(data: ChatRequest, request: Request, db: AsyncSes else: return JSONResponse(status_code=400, content={"detail": "Invalid artifact_id format"}) - execution_data = await prepare_execution_data( + # Execute batch actions using the service + service = BuildModeToolExecutor(chatbot=PlaneChatBot("gpt-4.1"), db=db) + result = await service.execute( ActionBatchExecutionRequest( workspace_id=workspace_id, chat_id=chat_id, @@ -241,29 +242,23 @@ async def get_answer_for_slack(data: ChatRequest, request: Request, db: AsyncSes access_token=access_token, ), user_id, - db, ) - if not execution_data: - # Determine the specific error based on what failed - if not await get_planned_actions_for_execution(message_id, chat_id, db): - return JSONResponse(status_code=404, content={"detail": BATCH_EXECUTION_ERRORS["NO_PLANNED_ACTIONS"]}) - elif not await get_original_user_query(message_id, db): - return JSONResponse(status_code=404, content={"detail": BATCH_EXECUTION_ERRORS["NO_ORIGINAL_QUERY"]}) - else: - # OAuth or workspace issue - return JSONResponse( - status_code=401, - content={ - "detail": BATCH_EXECUTION_ERRORS["OAUTH_REQUIRED"], - "error_code": "OAUTH_REQUIRED", - "workspace_id": str(data.workspace_id), - "user_id": str(data.user_id), - }, - ) - # Execute batch actions - context = await execute_batch_actions(execution_data, db) - formatted_context = format_execution_response(context) + # Check if service returned an error + if result.get("error"): + status_code = result.get("status_code", 500) + detail = result.get("detail", "Unknown error") + content = {"detail": detail} + if "error_code" in result: + content["error_code"] = result["error_code"] + if "workspace_id" in result: + content["workspace_id"] = result["workspace_id"] + if "user_id" in result: + content["user_id"] = result["user_id"] + return JSONResponse(status_code=status_code, content=content) + + # Extract the formatted context from successful result + formatted_context = result return JSONResponse( status_code=200, @@ -300,7 +295,6 @@ async def get_answer(data: ChatRequest, session: str = Depends(cookie_schema)): async def stream_response() -> AsyncGenerator[str, None]: token_id = None try: - pending_backticks = "" # Open a short-lived session for the duration of the streaming work async with get_streaming_db_session() as stream_db: # Heartbeat mechanism that does not cancel the underlying generator @@ -313,8 +307,11 @@ async def get_answer(data: ChatRequest, session: str = Depends(cookie_schema)): with contextlib.suppress(asyncio.CancelledError): await asyncio.sleep(10) - base_iter = chatbot.process_query_stream(data, db=stream_db) - next_chunk_task: asyncio.Task[str] = asyncio.create_task(cast(Coroutine[None, None, str], base_iter.__anext__())) + # Single unified call; internal routing happens in chat service + base_iter = chatbot.process_chat_stream(data, db=stream_db) + next_chunk_task: asyncio.Task[Union[str, Dict[str, Any]]] = asyncio.create_task( + cast(Coroutine[None, None, Union[str, Dict[str, Any]]], base_iter.__anext__()) + ) # Start initial heartbeat timer heartbeat_task = asyncio.create_task(heartbeat_emitter()) @@ -326,7 +323,7 @@ async def get_answer(data: ChatRequest, session: str = Depends(cookie_schema)): # If heartbeat timer completed first, emit heartbeat and restart timer if heartbeat_task in done and not heartbeat_stop.is_set(): - payload = {"reasoning": "⏳ Still working...\n\n"} + payload: Dict[str, Any] = {"header": "⏳ Still working...\n\n", "content": ""} yield f"event: reasoning\ndata: {json.dumps(payload)}\n\n" # Restart heartbeat timer heartbeat_task = asyncio.create_task(heartbeat_emitter()) @@ -348,17 +345,26 @@ async def get_answer(data: ChatRequest, session: str = Depends(cookie_schema)): heartbeat_task = asyncio.create_task(heartbeat_emitter()) # Normalize plain-text error chunks to SSE error events - normalized_error = normalize_error_chunk(chunk) + normalized_error = normalize_error_chunk(chunk) if isinstance(chunk, str) else None if normalized_error: yield normalized_error - next_chunk_task = asyncio.create_task(cast(Coroutine[None, None, str], base_iter.__anext__())) + next_chunk_task = asyncio.create_task(cast(Coroutine[None, None, Union[str, Dict[str, Any]]], base_iter.__anext__())) continue - - if chunk.startswith("πspecial reasoning blockπ: "): - reasoning_content = chunk.replace("πspecial reasoning blockπ: ", "") - payload = {"reasoning": reasoning_content} - yield f"event: reasoning\ndata: {json.dumps(payload)}\n\n" - elif chunk.startswith("πspecial actions blockπ: "): + # change point: "πspecial reasoning blockπ: + # handle the new json string format of reasoning blocks. It is a json string with header and content. + # add a condition to check if the chunk is a json string with header and content. + if isinstance(chunk, dict): + if "chunk_type" in chunk and chunk["chunk_type"] == "reasoning": + payload = {"header": chunk["header"], "content": chunk["content"]} + yield f"event: reasoning\ndata: {json.dumps(payload)}\n\n" + # for backward compatibility, we also need to yield the chunk as a string + # bwc_payload = {"reasoning": f"{chunk["header"]}\n\n{chunk["content"]}\n\n"} + # yield f"event: reasoning\ndata: {json.dumps(bwc_payload)}\n\n" + else: + log.warning(f"ChatID: {data.chat_id} - Chunk is not a json string: {chunk}") + payload = {"chunk": chunk} + yield f"event: delta\ndata: {json.dumps(payload)}\n\n" + elif isinstance(chunk, str) and chunk.startswith("πspecial actions blockπ: "): actions_content = chunk.replace("πspecial actions blockπ: ", "") try: actions_data = json.loads(actions_content) @@ -367,11 +373,13 @@ async def get_answer(data: ChatRequest, session: str = Depends(cookie_schema)): log.warning(f"Failed to parse actions JSON: {actions_content}") payload = {"chunk": chunk} yield f"event: delta\ndata: {json.dumps(payload)}\n\n" - elif chunk.startswith("πspecial clarification blockπ: "): + elif isinstance(chunk, str) and chunk.startswith("πspecial clarification blockπ: "): clarification_content = chunk.replace("πspecial clarification blockπ: ", "") try: clarification_data = json.loads(clarification_content) + log.info(f"ChatID: {data.chat_id} - Clarification data received in the endpoint: {clarification_data}") formatted_text = format_clarification_as_text(clarification_data) + log.info(f"ChatID: {data.chat_id} - Clarification formatted text: {formatted_text}") payload = {"chunk": formatted_text} yield f"event: delta\ndata: {json.dumps(payload)}\n\n" except json.JSONDecodeError: @@ -381,24 +389,8 @@ async def get_answer(data: ChatRequest, session: str = Depends(cookie_schema)): } yield f"event: delta\ndata: {json.dumps(payload)}\n\n" else: - # Handle code block formatting - if chunk.startswith("```"): - if pending_backticks: - payload = {"chunk": pending_backticks} - yield f"event: delta\ndata: {json.dumps(payload)}\n\n" - pending_backticks = "" - payload = {"chunk": chunk} - yield f"event: delta\ndata: {json.dumps(payload)}\n\n" - elif chunk in ["`", "``"]: - pending_backticks += chunk - next_chunk_task = asyncio.create_task(cast(Coroutine[None, None, str], base_iter.__anext__())) - continue - else: - if pending_backticks: - chunk = pending_backticks + chunk - pending_backticks = "" - payload = {"chunk": chunk} - yield f"event: delta\ndata: {json.dumps(payload)}\n\n" + payload = {"chunk": chunk} + yield f"event: delta\ndata: {json.dumps(payload)}\n\n" # Prepare next iteration next_chunk_task = asyncio.create_task(cast(Coroutine[None, None, str], base_iter.__anext__())) @@ -409,11 +401,6 @@ async def get_answer(data: ChatRequest, session: str = Depends(cookie_schema)): with contextlib.suppress(asyncio.CancelledError): await heartbeat_task - # Handle any remaining pending backticks at the end of stream - if pending_backticks: - payload = {"chunk": pending_backticks} - yield f"event: delta\ndata: {json.dumps(payload)}\n\n" - # Extract token_id from data.context if available for background task if hasattr(data, "context") and isinstance(data.context, dict): token_id = data.context.get("token_id") @@ -587,8 +574,11 @@ async def get_chat_history( "feedback": results.get("feedback", ""), "reasoning": results.get("reasoning", ""), "is_focus_enabled": results.get("is_focus_enabled", False), + "focus_entity_type": results.get("focus_entity_type", None), + "focus_entity_id": results.get("focus_entity_id", None), "focus_project_id": results.get("focus_project_id", None), "focus_workspace_id": results.get("focus_workspace_id", None), + "mode": results.get("mode", "ask"), }, }, ) @@ -604,8 +594,11 @@ async def get_chat_history( "feedback": results.get("feedback", ""), "reasoning": results.get("reasoning", ""), "is_focus_enabled": results.get("is_focus_enabled", False), + "focus_entity_type": results.get("focus_entity_type", None), + "focus_entity_id": results.get("focus_entity_id", None), "focus_project_id": results.get("focus_project_id", None), "focus_workspace_id": results.get("focus_workspace_id", None), + "mode": results.get("mode", "ask"), }, }, ) @@ -619,8 +612,11 @@ async def get_chat_history( "feedback": results["feedback"], "reasoning": results.get("reasoning", ""), "is_focus_enabled": results.get("is_focus_enabled", False), + "focus_entity_type": results.get("focus_entity_type", None), + "focus_entity_id": results.get("focus_entity_id", None), "focus_project_id": results.get("focus_project_id", None), "focus_workspace_id": results.get("focus_workspace_id", None), + "mode": results.get("mode", "ask"), } } ) @@ -671,8 +667,11 @@ async def get_chat_history_object( "feedback": results["feedback"], "reasoning": results.get("reasoning", ""), "is_focus_enabled": results.get("is_focus_enabled", False), + "focus_entity_type": results.get("focus_entity_type", None), + "focus_entity_id": results.get("focus_entity_id", None), "focus_project_id": results.get("focus_project_id", None), "focus_workspace_id": results.get("focus_workspace_id", None), + "mode": results.get("mode", "ask"), } } ) @@ -947,6 +946,7 @@ async def queue_answer(data: ChatRequest, db: AsyncSession = Depends(get_async_s user_type=UserTypeChoices.USER.value, llm_model=data.llm, workspace_slug=resolved_workspace_slug, + source=data.source or None, db=db, ) @@ -1029,14 +1029,14 @@ async def stream_answer(token: UUID4, db: AsyncSession = Depends(get_async_sessi if not flow_step: # REGENERATE FLOW: No QUEUE flow step means this is regenerate log.info(f"No QUEUE flow step found for token {token}. Treating as regenerate request.") - # 1. Get the user message + # Get the user message user_message = await get_message_by_id(db, token) if not user_message: log.warning(f"Message not found for token {token}") return JSONResponse(status_code=404, content={"detail": "Message not found"}) - # 2. Mark old assistant response as replaced BEFORE generating new one + # Mark old assistant response as replaced BEFORE generating new one # This ensures retrieve_chat_history() won't include it in context for new generation marked = await mark_assistant_response_as_replaced(db, user_message.id) if marked: @@ -1044,14 +1044,14 @@ async def stream_answer(token: UUID4, db: AsyncSession = Depends(get_async_sessi else: log.info(f"No existing assistant response found for message {token} (first generation or already replaced)") - # 4. Reconstruct ChatRequest from user message + # Reconstruct ChatRequest from user message try: queued_request = await reconstruct_chat_request_from_message(db, user_message, user_id) except Exception as e: log.error(f"Error reconstructing ChatRequest from message {token}: {e}") return JSONResponse(status_code=500, content={"detail": "Failed to reconstruct request"}) - # 5. Pass token_id so new assistant message reuses same user message as parent + # Pass token_id so new assistant message reuses same user message as parent try: queued_request.context["token_id"] = str(token) except Exception as e: @@ -1059,7 +1059,7 @@ async def stream_answer(token: UUID4, db: AsyncSession = Depends(get_async_sessi log.info(f"Regenerating response for message {token}") - # 6. Stream new response (get_answer will call process_query_stream) + # Stream new response (get_answer will call process_query_stream) # When process_query_stream calls retrieve_chat_history, # it will NOT see the old response because is_replaced=True return await get_answer(data=queued_request, session=session) @@ -1123,40 +1123,37 @@ async def execute_action(request: ActionBatchExecutionRequest, db: AsyncSession # 3. Assistant messages are updated with execution results and entity information # 4. Conversation history includes explicit text about executed vs. not executed actions # This ensures complete context for follow-up questions and LLM understanding. + try: # Validate session and get user - user_id = await validate_session_and_get_user(session) - if not user_id: - return JSONResponse(status_code=401, content={"detail": BATCH_EXECUTION_ERRORS["INVALID_SESSION"]}) + auth = await is_valid_session(session) + if not auth.user: + return JSONResponse(status_code=401, content={"detail": "Invalid User"}) - # Validate and prepare execution data - execution_data = await prepare_execution_data(request, user_id, db) - if not execution_data: - # Determine the specific error based on what failed - if not await get_planned_actions_for_execution(request.message_id, request.chat_id, db): - return JSONResponse(status_code=404, content={"detail": BATCH_EXECUTION_ERRORS["NO_PLANNED_ACTIONS"]}) - elif not await get_original_user_query(request.message_id, db): - return JSONResponse(status_code=404, content={"detail": BATCH_EXECUTION_ERRORS["NO_ORIGINAL_QUERY"]}) - else: - # OAuth or workspace issue - return JSONResponse( - status_code=401, - content={ - "detail": BATCH_EXECUTION_ERRORS["OAUTH_REQUIRED"], - "error_code": "OAUTH_REQUIRED", - "workspace_id": str(request.workspace_id), - "user_id": str(user_id), - }, - ) + llm_model = await chosen_llm(db=db, message_id=request.message_id) + # Use default model if none was found in the message + chatbot = PlaneChatBot(llm_model or "gpt-4.1") + build_mode_tool_executor = BuildModeToolExecutor(chatbot=chatbot, db=db) + result = await build_mode_tool_executor.execute(request, auth.user.id) - # Execute batch actions - context = await execute_batch_actions(execution_data, db) + # Check if service returned an error + if result.get("error"): + status_code = result.get("status_code", 500) + detail = result.get("detail", "Unknown error") - # Update the assistant message with execution results - await update_assistant_message_with_execution_results(request.message_id, request.chat_id, context, db) + # Build response content + content = {"detail": detail} + if "error_code" in result: + content["error_code"] = result["error_code"] + if "workspace_id" in result: + content["workspace_id"] = result["workspace_id"] + if "user_id" in result: + content["user_id"] = result["user_id"] - # Return appropriate response based on execution status - return JSONResponse(content=format_execution_response(context)) + return JSONResponse(status_code=status_code, content=content) + + # Return successful response + return JSONResponse(content=result) except Exception as e: log.error(f"Error in execute_action: {str(e)}") diff --git a/apps/pi/pi/app/api/v1/endpoints/mobile/chat.py b/apps/pi/pi/app/api/v1/endpoints/mobile/chat.py index 79b50c81b6..bc22eaae48 100644 --- a/apps/pi/pi/app/api/v1/endpoints/mobile/chat.py +++ b/apps/pi/pi/app/api/v1/endpoints/mobile/chat.py @@ -3,8 +3,11 @@ import asyncio import contextlib import json import uuid +from typing import Any from typing import Coroutine +from typing import Dict from typing import Optional +from typing import Union from typing import cast from fastapi import APIRouter @@ -18,12 +21,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from pi import logger from pi.app.api.v1.dependencies import jwt_schema from pi.app.api.v1.dependencies import validate_jwt_token -from pi.app.api.v1.helpers.plane_sql_queries import resolve_workspace_id_from_project_id -from pi.app.models.enums import FlowStepType -from pi.app.models.enums import UserTypeChoices -from pi.app.models.message import MessageFlowStep from pi.app.schemas.mobile.chat import ChatFeedbackMobile -from pi.app.schemas.mobile.chat import ChatInitializationRequestMobile from pi.app.schemas.mobile.chat import ChatRequestMobile from pi.app.schemas.mobile.chat import ChatSearchResponseMobile from pi.app.schemas.mobile.chat import ChatStartResponseMobile @@ -36,8 +34,6 @@ from pi.app.schemas.mobile.chat import ModelsResponseMobile from pi.app.schemas.mobile.chat import RenameChatRequestMobile from pi.app.schemas.mobile.chat import TitleRequestMobile from pi.app.schemas.mobile.chat import UnfavoriteChatRequestMobile -from pi.app.utils import validate_chat_initialization -from pi.app.utils import validate_chat_request from pi.app.utils.background_tasks import schedule_chat_deletion from pi.app.utils.background_tasks import schedule_chat_rename from pi.app.utils.background_tasks import schedule_chat_search_upsert @@ -48,8 +44,6 @@ from pi.services.chat.chat import PlaneChatBot from pi.services.chat.helpers.tool_utils import format_clarification_as_text from pi.services.chat.search import ChatSearchService from pi.services.chat.templates import tiles_factory -from pi.services.chat.utils import initialize_new_chat -from pi.services.chat.utils import resolve_workspace_slug from pi.services.retrievers.pg_store import favorite_chat from pi.services.retrievers.pg_store import get_active_models from pi.services.retrievers.pg_store import get_chat_messages @@ -60,11 +54,6 @@ from pi.services.retrievers.pg_store import retrieve_chat_history from pi.services.retrievers.pg_store import soft_delete_chat from pi.services.retrievers.pg_store import unfavorite_chat from pi.services.retrievers.pg_store import update_message_feedback -from pi.services.retrievers.pg_store.message import get_message_by_id -from pi.services.retrievers.pg_store.message import mark_assistant_response_as_replaced -from pi.services.retrievers.pg_store.message import reconstruct_chat_request_from_message -from pi.services.retrievers.pg_store.message import upsert_message -from pi.services.retrievers.pg_store.message import upsert_message_flow_steps log = logger.getChild("v1/mobile/chat") mobile_router = APIRouter() @@ -109,8 +98,10 @@ async def get_answer( with contextlib.suppress(asyncio.CancelledError): await asyncio.sleep(10) - base_iter = chatbot.process_query_stream(data, stream_db) - next_chunk_task: asyncio.Task[str] = asyncio.create_task(cast(Coroutine[None, None, str], base_iter.__anext__())) + base_iter = chatbot.process_chat_stream(data, stream_db) + next_chunk_task: asyncio.Task[Union[str, Dict[str, Any]]] = asyncio.create_task( + cast(Coroutine[None, None, Union[str, Dict[str, Any]]], base_iter.__anext__()) + ) # Start initial heartbeat timer heartbeat_task = asyncio.create_task(heartbeat_emitter()) @@ -122,7 +113,8 @@ async def get_answer( # If heartbeat timer completed first, emit heartbeat and restart timer if heartbeat_task in done and not heartbeat_stop.is_set(): - yield "event: reasoning\ndata: ⏳ Still working...\n\n" + heartbeat_payload = {"reasoning": "⏳ Still working..."} + yield f"event: reasoning\ndata: {json.dumps(heartbeat_payload)}\n\n" # Restart heartbeat timer heartbeat_task = asyncio.create_task(heartbeat_emitter()) @@ -142,10 +134,17 @@ async def get_answer( await heartbeat_task heartbeat_task = asyncio.create_task(heartbeat_emitter()) - if chunk.startswith("πspecial reasoning blockπ: "): - reasoning_content = chunk.replace("πspecial reasoning blockπ: ", "") - yield f"event: reasoning\ndata: {reasoning_content}\n\n" - elif chunk.startswith("πspecial clarification blockπ: "): + if isinstance(chunk, dict): + if "chunk_type" in chunk and chunk["chunk_type"] == "reasoning": + # Till the mobile team updates the app to support json format, we need to yield the chunk as a string + bwc_payload = {"reasoning": f"{chunk["header"]}\n\n{chunk["content"]}\n\n"} + # payload = {'header': chunk.get('header', ''), 'content': chunk.get('content', '')} + # yield f"event: reasoning\ndata: {json.dumps(payload)}\n\n" + yield f"event: reasoning\ndata: {json.dumps(bwc_payload)}\n\n" + else: + payload: Dict[str, Any] = {"chunk": chunk} + yield f"event: delta\ndata: {json.dumps(payload)}\n\n" + elif isinstance(chunk, str) and chunk.startswith("πspecial clarification blockπ: "): clarification_content = chunk.replace("πspecial clarification blockπ: ", "") try: clarification_data = json.loads(clarification_content) @@ -155,19 +154,21 @@ async def get_answer( log.warning(f"Failed to parse clarification JSON: {clarification_content}") formatted_text = "I'm sorry, I can't understand your request in your workspace context. Can you be more specific?" yield f"event: delta\ndata: {formatted_text}\n\n" - elif chunk.startswith("πspecial actions blockπ: "): + elif isinstance(chunk, str) and chunk.startswith("πspecial actions blockπ: "): actions_content = chunk.replace("πspecial actions blockπ: ", "") try: actions_data = json.loads(actions_content) yield f"event: actions\ndata: {json.dumps(actions_data)}\n\n" except json.JSONDecodeError: log.warning(f"Failed to parse actions JSON: {actions_content}") - yield f"event: delta\ndata: {chunk}\n\n" + payload = {"chunk": chunk} + yield f"event: delta\ndata: {json.dumps(payload)}\n\n" else: - yield f"event: delta\ndata: {chunk}\n\n" + payload = {"chunk": chunk} + yield f"event: delta\ndata: {json.dumps(payload)}\n\n" # Prepare next iteration - next_chunk_task = asyncio.create_task(cast(Coroutine[None, None, str], base_iter.__anext__())) + next_chunk_task = asyncio.create_task(cast(Coroutine[None, None, Union[str, Dict[str, Any]]], base_iter.__anext__())) finally: heartbeat_stop.set() if heartbeat_task and not heartbeat_task.done(): @@ -197,285 +198,6 @@ async def get_answer( return JSONResponse(status_code=500, content={"detail": "Internal Server Error"}) -@mobile_router.post("/initialize-chat/") -async def initialize_chat( - data: ChatInitializationRequestMobile, - db: AsyncSession = Depends(get_async_session), - token: HTTPAuthorizationCredentials = Depends(jwt_schema), -): - """Initialize a new chat and return the chat_id immediately.""" - try: - auth = await validate_jwt_token(token) - if not auth.user: - return JSONResponse(status_code=401, content={"detail": "Invalid User"}) - user_id = auth.user.id - except Exception as e: - log.error(f"Error validating JWT: {e!s}") - return JSONResponse(status_code=401, content={"detail": "Invalid JWT"}) - - # Validate request data - validation_error = validate_chat_initialization(data) - if validation_error: - return JSONResponse(status_code=validation_error["status_code"], content={"detail": validation_error["detail"]}) - - # Initialize chat using standalone function (workspace details backfilled later in queue_answer) - result = await initialize_new_chat( - user_id=user_id, - db=db, - chat_id=data.chat_id, - is_project_chat=data.is_project_chat, - workspace_in_context=data.workspace_in_context, - workspace_id=data.workspace_id, - ) - - # Handle result from service layer - if result["success"]: - return JSONResponse(content={"chat_id": result["chat_id"]}) - else: - # Map service error codes to HTTP status codes - status_code = 500 # default - if result.get("error_code") == "CHAT_EXISTS": - status_code = 409 - elif result.get("error_code") == "CHAT_CREATION_FAILED": - status_code = 500 - elif result.get("error_code") == "UNEXPECTED_ERROR": - status_code = 500 - - return JSONResponse(status_code=status_code, content={"detail": result["message"]}) - - -@mobile_router.post("/queue-answer/") -async def queue_answer( - data: ChatRequestMobile, - db: AsyncSession = Depends(get_async_session), - token: HTTPAuthorizationCredentials = Depends(jwt_schema), -): - """First phase of two-step streaming flow. - Persists the ChatRequest payload and returns a one-time stream token. - The token is simply the UUID of a freshly created *user* Message row so we - don't need a new table. The rest of the ChatRequest fields are stored in - a MessageFlowStep (tool_name="QUEUE", step_order=0) until the client - later redeems the token via /stream-answer/{token}.""" - try: - auth = await validate_jwt_token(token) - if not auth.user: - return JSONResponse(status_code=401, content={"detail": "Invalid User"}) - except Exception as e: - log.error(f"Error validating JWT: {e!s}") - return JSONResponse(status_code=401, content={"detail": "Invalid JWT"}) - - validation_error = validate_chat_request(data) - if validation_error: - return JSONResponse(status_code=validation_error["status_code"], content={"detail": validation_error["detail"]}) - - # 1. Create a new USER message that will serve as the token - if data.chat_id is None: - return JSONResponse(status_code=400, content={"detail": "chat_id is required. Call /initialize-chat/ first."}) - - ## need to resolve ws id and slug from project id if it's project chat - - if not data.workspace_id: - if data.is_project_chat and data.project_id: - log.info(f"Mobile Queue-answer: Resolving workspace_id from project_id: {data.project_id}") - resolved_workspace_id = await resolve_workspace_id_from_project_id(str(data.project_id)) - # The DB may return an asyncpg UUID object. Convert safely to a standard uuid.UUID. - from uuid import UUID - - workspace_id_to_use = UUID(str(resolved_workspace_id)) if resolved_workspace_id else None - log.info(f"Mobile Queue-answer: Resolved workspace_id: {workspace_id_to_use}") - else: - workspace_id_to_use = data.workspace_id - if not data.workspace_slug: - if workspace_id_to_use: - resolved_workspace_slug = await resolve_workspace_slug(workspace_id_to_use, data.workspace_slug) - else: - log.warning("Mobile Queue-answer: No workspace_id to resolve workspace_slug from") - resolved_workspace_slug = None - else: - resolved_workspace_slug = data.workspace_slug - - from uuid import uuid4 - - token_id = uuid4() - user_message_res = await upsert_message( - message_id=token_id, - chat_id=data.chat_id, # type: ignore[arg-type] - content=data.query, - parsed_content=None, - user_type=UserTypeChoices.USER.value, - llm_model=data.llm, - workspace_slug=resolved_workspace_slug, - db=db, - ) - - if user_message_res.get("message") != "success": - return JSONResponse(status_code=500, content={"detail": "Failed to create message"}) - - # 2. Stash the full ChatRequest inside a flow-step row - from fastapi.encoders import jsonable_encoder - - flow_step_payload = { - "step_order": 0, - "step_type": FlowStepType.TOOL.value, - "tool_name": "QUEUE", - "content": "queued chat request", - # Use FastAPI's encoder to turn UUIDs/datetimes into JSON-serialisable primitives - "execution_data": jsonable_encoder(data), - "is_planned": False, # QUEUE is not a planned action, it's an internal tool - "is_executed": False, # QUEUE is not executed by user - } - flow_res = await upsert_message_flow_steps(message_id=token_id, chat_id=data.chat_id, db=db, flow_steps=[flow_step_payload]) - - if flow_res.get("message") != "success": - return JSONResponse(status_code=500, content={"detail": "Failed to queue request"}) - - # Backfill chat record with workspace details (after successful message creation) - try: - from sqlalchemy import select - - from pi.app.models.chat import Chat - - # Get the current chat record to preserve existing data - chat_stmt = select(Chat).where(Chat.id == data.chat_id) # type: ignore[arg-type] - chat_result = await db.execute(chat_stmt) - existing_chat = chat_result.scalar_one_or_none() - - if existing_chat: - # Update the chat with workspace details - if workspace_id_to_use is not None: - existing_chat.workspace_id = workspace_id_to_use - if resolved_workspace_slug is not None: - existing_chat.workspace_slug = resolved_workspace_slug - # Always update workspace_in_context as it's a required field in ChatRequest - existing_chat.workspace_in_context = data.workspace_in_context - await db.commit() - else: - log.warning(f"Chat {data.chat_id} not found for backfill") - except Exception as e: - log.error(f"Error backfilling chat workspace details: {e}") - # Don't fail the request if backfill fails - - return {"stream_token": str(token_id)} - - -@mobile_router.get("/stream-answer/{token}") -async def stream_answer( - token: UUID4, - db: AsyncSession = Depends(get_async_session), - auth_token: HTTPAuthorizationCredentials = Depends(jwt_schema), -): - """Second phase of two-step flow. - Looks up the queued ChatRequest by token (message_id), deletes the queue - entry, and then re-uses the existing /get-answer/ logic to start the SSE - stream via a pure GET endpoint. - - Also handles REGENERATE when called with an existing user message ID - (no QUEUE flow step present).""" - try: - auth = await validate_jwt_token(auth_token) - if not auth.user: - return JSONResponse(status_code=401, content={"detail": "Invalid User"}) - user_id = auth.user.id - except Exception: - return JSONResponse(status_code=401, content={"detail": "Invalid JWT"}) - - # Locate the queued flow step - from sqlalchemy import select - - stmt = ( - select(MessageFlowStep) - .where(MessageFlowStep.message_id == token) # type: ignore[arg-type] - .where(MessageFlowStep.tool_name == "QUEUE") # type: ignore[arg-type] - ) - res = await db.execute(stmt) - flow_step: MessageFlowStep | None = res.scalar_one_or_none() - - if not flow_step: - # REGENERATE FLOW: No QUEUE flow step means this is regenerate - log.info(f"Mobile: No QUEUE flow step found for token {token}. Treating as regenerate request.") - # 1. Get the user message - user_message = await get_message_by_id(db, token) - - if not user_message: - log.warning(f"Mobile: Message not found for token {token}") - return JSONResponse(status_code=404, content={"detail": "Message not found"}) - - # 2. Mark old assistant response as replaced BEFORE generating new one - # This ensures retrieve_chat_history() won't include it in context for new generation - marked = await mark_assistant_response_as_replaced(db, user_message.id) - if marked: - log.info(f"Mobile: Marked old assistant response(s) as replaced for message {token}") - else: - log.info(f"Mobile: No existing assistant response found for message {token} (first generation or already replaced)") - - # 4. Reconstruct ChatRequest from user message - try: - queued_request = await reconstruct_chat_request_from_message(db, user_message, user_id) - except Exception as e: - log.error(f"Mobile: Error reconstructing ChatRequest from message {token}: {e}") - return JSONResponse(status_code=500, content={"detail": "Failed to reconstruct request"}) - - # 5. Pass token_id so new assistant message reuses same user message as parent - try: - queued_request.context["token_id"] = str(token) - except Exception as e: - log.warning(f"Mobile: Failed to attach token_id to regenerate request context: {e!s}") - - log.info(f"Mobile: Regenerating response for message {token}") - - # 6. Stream new response (get_answer will call process_query_stream) - # When process_query_stream calls retrieve_chat_history, - # it will NOT see the old response because is_replaced=True - return await get_answer(data=queued_request, token=auth_token, db=db) - - else: - # NORMAL FLOW: QUEUE flow step exists, this is first generation - # Parse the stored ChatRequest - try: - raw_data = flow_step.execution_data or {} - # Check if this is an OAuth message (has oauth_required field) - if flow_step.oauth_required: - # This is an OAuth message - check if OAuth is now complete - # by looking at the oauth_completed column - if flow_step.oauth_completed: - # OAuth is now complete! Process the request normally - log.info(f"Mobile: OAuth completed for message {token}. Processing request.") - # Control continues to the normal processing code below - else: - # OAuth is still required - return JSONResponse( - status_code=401, - content={ - "detail": "OAuth authorization still required. Please complete OAuth authentication first.", - "error_code": "OAUTH_REQUIRED", - }, - ) - - # Convert empty-string UUIDs to None so pydantic validation passes - for field in ["project_id", "workspace_id", "chat_id"]: - if field in raw_data and raw_data[field] == "": - raw_data[field] = None - raw_data["user_id"] = user_id - queued_request = ChatRequestMobile.parse_obj(raw_data) - - except Exception as e: - log.error(f"Mobile: Malformed execution_data for token {token}: {e!s}") - return JSONResponse(status_code=500, content={"detail": "Corrupted queued request"}) - - # Consume the queue entry so the token is single-use (for normal flow) - await db.delete(flow_step) - await db.commit() - - # Pass the token/message_id forward so downstream processing reuses the same Message row - try: - queued_request.context["token_id"] = str(token) - except Exception as e: - log.warning(f"Mobile: Failed to attach token_id to queued request context: {e!s}") - - # Delegate to existing get_answer for streaming - return await get_answer(data=queued_request, token=auth_token, db=db) - - @mobile_router.delete("/delete-chat/") async def delete_chat( data: DeleteChatRequestMobile, @@ -617,8 +339,11 @@ async def get_chat_history( "feedback": results["feedback"], "reasoning": results.get("reasoning", ""), "is_focus_enabled": results.get("is_focus_enabled", False), + "focus_entity_type": results.get("focus_entity_type", None), + "focus_entity_id": results.get("focus_entity_id", None), "focus_project_id": results.get("focus_project_id", None), "focus_workspace_id": results.get("focus_workspace_id", None), + "mode": results.get("mode", "ask"), } } ) @@ -658,8 +383,11 @@ async def get_chat_history_object( "feedback": results["feedback"], "reasoning": results.get("reasoning", ""), "is_focus_enabled": results.get("is_focus_enabled", False), + "focus_entity_type": results.get("focus_entity_type", None), + "focus_entity_id": results.get("focus_entity_id", None), "focus_project_id": results.get("focus_project_id", None), "focus_workspace_id": results.get("focus_workspace_id", None), + "mode": results.get("mode", "ask"), } } ) diff --git a/apps/pi/pi/app/api/v1/helpers/batch_execution_helpers.py b/apps/pi/pi/app/api/v1/helpers/batch_execution_helpers.py deleted file mode 100644 index be2076e990..0000000000 --- a/apps/pi/pi/app/api/v1/helpers/batch_execution_helpers.py +++ /dev/null @@ -1,615 +0,0 @@ -""" -Helper functions for batch action execution in chat endpoints. -""" - -import datetime -import logging -from typing import Any -from typing import Dict -from typing import List -from typing import Optional -from typing import Set -from uuid import UUID - -from sqlalchemy import select -from sqlmodel.ext.asyncio.session import AsyncSession - -from pi.app.models.enums import UserTypeChoices -from pi.app.models.message import Message -from pi.app.schemas.chat import ActionBatchExecutionRequest -from pi.app.schemas.chat import ArtifactData -from pi.services.chat.chat import PlaneChatBot -from pi.services.chat.helpers.batch_action_orchestrator import BatchActionOrchestrator -from pi.services.chat.helpers.batch_execution_context import BatchExecutionContext -from pi.services.chat.helpers.batch_execution_helpers import get_planned_actions_for_execution -from pi.services.chat.helpers.tool_utils import classify_tool -from pi.services.retrievers.pg_store.action_artifact import get_action_artifacts_by_ids - -log = logging.getLogger(__name__) - -# Registry to handle project/workspace id for entities during manual edits. -# HOW IT WORKS: -# 1. During planning, if project_id is missing, user gets clarification prompt -# 2. For workspace-scope entities, clarification includes "Workspace level" option -# 3. User selects either "Workspace level" or a specific project -# 4. Artifact stores project_id as either "__workspace_scope__" or a UUID -# 5. During execution, the tool checks project_id and routes accordingly: -# - "__workspace_scope__" → calls workspace-level API (e.g., create_workspace_page) -# - UUID → calls project-level API (e.g., create_project_page) -# -# EDITED ARTIFACTS: -# When user edits an artifact, the resolved project_id must be preserved. -# The _extract_project_id_from_artifact() helper extracts it from planning data. -WORKSPACE_SCOPE_ENTITIES: Set[str] = { - "page", # Pages: workspace wiki OR project pages -} - - -def _is_valid_uuid(uuid_string: str) -> bool: - """Check if a string is a valid UUID format.""" - try: - UUID(uuid_string) - return True - except (ValueError, TypeError): - return False - - -def _extract_project_id_from_artifact(artifact_data: Dict[str, Any]) -> Optional[str]: - """Extract project_id from artifact data, checking multiple possible locations. - - This handles the various ways project_id can be stored in artifact data: - 1. In tool_args (most direct) - 2. In planning_data.parameters (from planning phase) - 3. In planning_data.parameters.project.id (nested structure) - - Returns: - project_id as string (can be UUID or "__workspace_scope__"), or None if not found - """ - # Try tool_args first (most direct) - tool_args = artifact_data.get("tool_args", {}) - if isinstance(tool_args, dict) and tool_args.get("project_id"): - return str(tool_args["project_id"]) - - # Try planning_data structure - planning_data = artifact_data.get("planning_data", {}) - if isinstance(planning_data, dict): - parameters = planning_data.get("parameters", {}) - if isinstance(parameters, dict): - # Direct project_id in parameters - if parameters.get("project_id"): - return str(parameters["project_id"]) - - # Nested project object with id - project_obj = parameters.get("project") - if isinstance(project_obj, dict) and project_obj.get("id"): - return str(project_obj["id"]) - - return None - - -def _extract_tool_args_from_artifact_data(artifact_data: dict, entity_type: str) -> dict: - """ - Extract and format tool arguments from artifact data for execution. - - Handles different data formats: - 1. Direct tool_args (from planning phase) - 2. Nested parameters structure (from planning_data) - 3. Flattened parameters (from UI edits) - """ - try: - # First, try direct tool_args (most reliable) - if "tool_args" in artifact_data: - tool_args = artifact_data["tool_args"] - if isinstance(tool_args, dict) and tool_args: - return tool_args - - # Next, try planning_data.parameters structure - if "planning_data" in artifact_data: - planning_data = artifact_data["planning_data"] - if isinstance(planning_data, dict): - parameters = planning_data.get("parameters", {}) - if isinstance(parameters, dict): - # Check for nested entity structure - if entity_type in parameters: - entity_params = parameters[entity_type] - if isinstance(entity_params, dict): - # Flatten entity parameters and merge with other parameters - flattened = dict(entity_params) - # Add other non-entity parameters - for key, value in parameters.items(): - if key != entity_type: - flattened[key] = value - return flattened - else: - # Parameters are already flattened - return parameters - - # Fallback: return empty dict if no valid args found - log.warning(f"No valid tool args found in artifact data for entity {entity_type}") - return {} - - except Exception as e: - log.error(f"Error extracting tool args from artifact data: {e}") - return {} - - -async def validate_session_and_get_user(session: str) -> Optional[UUID]: - """Validate session and return user ID if valid.""" - try: - from pi.app.api.v1.dependencies import is_valid_session - - auth = await is_valid_session(session) - if not auth.user: - return None - return auth.user.id - except Exception as e: - log.error(f"Error validating session: {e!s}") - return None - - -async def prepare_execution_data(request: ActionBatchExecutionRequest, user_id: UUID, db: AsyncSession) -> Optional[dict]: - """Prepare execution data and return error response if validation fails.""" - try: - # **ARTIFACT-BASED EXECUTION** - # Prefer explicit artifact_data from the client. If absent, fall back to executing - # all planned artifacts for this message to maintain backward compatibility. - artifact_items = request.artifact_data or [] - - if not artifact_items: - log.info("No artifact_data provided in request; attempting fallback to planned actions") - planned_for_message = await get_planned_actions_for_execution(request.message_id, request.chat_id, db) - if not planned_for_message: - log.error("No artifact_data provided and no planned actions found") - return None - - # Build a default list of artifacts to execute (all planned, unedited) - tmp_items: list[ArtifactData] = [] - for pa in planned_for_message: - aid = pa.get("artifact_id") - if not aid: - continue - try: - tmp_items.append(ArtifactData(artifact_id=UUID(aid), is_edited=False)) - except Exception as e: - log.error(f"Invalid artifact_id in planned actions: {aid}: {e}") - - if not tmp_items: - log.error("No valid artifact IDs found in planned actions for fallback execution") - return None - - artifact_items = tmp_items - - log.info(f"Processing artifacts: {len(artifact_items)} artifacts") - - planned_actions: List[Dict[str, Any]] = [] - - for artifact_item in artifact_items: - log.info(f"Processing artifact: {artifact_item.artifact_id} (is_edited: {artifact_item.is_edited})") - - # Get artifact metadata - artifacts = await get_action_artifacts_by_ids(db, [artifact_item.artifact_id]) - if not artifacts: - log.error(f"Artifact {artifact_item.artifact_id} not found") - continue - - artifact = artifacts[0] - entity_type = artifact.entity - entity_id = artifact.entity_id - original_action = artifact.action - - if artifact_item.is_edited: - # **EDITED ARTIFACT** - Create ActionArtifactVersion and use its UUID - - if not artifact_item.action_data: - log.error(f"Artifact {artifact_item.artifact_id} marked as edited but no action_data provided") - continue - - # For edited artifacts, use the provided action_data directly - tool_args = artifact_item.action_data.copy() - current_project_id = tool_args.get("project_id") - - # Check if project_id is missing or invalid (needs to be preserved from original) - needs_project_id = ( - not current_project_id - or current_project_id == "NEEDS_CLARIFICATION" - or (isinstance(current_project_id, str) and current_project_id.startswith(" 0 and edited_count > 0: - original_query = f"Mixed execution: {normal_count} normal + {edited_count} edited artifacts" - elif edited_count > 0: - original_query = f"Execute {edited_count} edited artifacts" - else: - original_query = f"Execute {normal_count} artifacts" - - # Get OAuth token for the user - chatbot = PlaneChatBot() - access_token = request.access_token or await chatbot._get_oauth_token_for_user(db, str(user_id), str(request.workspace_id)) - - if not access_token: - log.error(f"No valid OAuth token found for user {user_id} and workspace {request.workspace_id}") - return None - - # Get workspace context - from pi.app.api.v1.helpers.plane_sql_queries import get_workspace_slug - - workspace_slug = await get_workspace_slug(str(request.workspace_id)) - - if not workspace_slug: - return None - - # Get is_project_chat from chat record (with explicit commit/refresh to avoid race condition) - from sqlalchemy import select - - from pi.app.models.chat import Chat - - # Refresh the session to ensure we see any committed chat records - await db.commit() - - stmt = select(Chat).where(Chat.id == request.chat_id) # type: ignore[arg-type] - result = await db.execute(stmt) - chat = result.scalar_one_or_none() - is_project_chat = chat.is_project_chat if chat else False - - log.info(f"🔧 EXECUTION DATA PREP: chat_id={request.chat_id}, is_project_chat from DB (after commit)={is_project_chat}") - - # Return successful execution data - return { - "planned_actions": planned_actions, - "original_query": original_query, - "access_token": access_token, - "workspace_slug": workspace_slug, - "user_id": user_id, - "workspace_id": request.workspace_id, - "message_id": request.message_id, - "chat_id": request.chat_id, - "is_project_chat": is_project_chat, - } - except Exception as e: - log.error(f"Error preparing execution data: {e}") - return None - - -async def execute_batch_actions(execution_data: dict, db: AsyncSession) -> BatchExecutionContext: - """Execute batch actions using the orchestrator.""" - try: - # Create execution context - context = BatchExecutionContext( - message_id=execution_data["message_id"], - chat_id=execution_data["chat_id"], - user_id=execution_data["user_id"], - workspace_id=execution_data["workspace_id"], - is_project_chat=execution_data.get("is_project_chat", False), - ) - - # Create orchestrator and execute batch - chatbot = PlaneChatBot("gpt-4.1", execution_data["access_token"]) - orchestrator = BatchActionOrchestrator(chatbot, db) - - context = await orchestrator.execute_planned_actions( - context, - execution_data["original_query"], - execution_data["planned_actions"], - execution_data["access_token"], - execution_data["workspace_slug"], - ) - - return context - - except Exception as e: - log.error(f"Error executing batch actions: {e}") - # Create a failed context to return - context = BatchExecutionContext( - message_id=execution_data["message_id"], - chat_id=execution_data["chat_id"], - user_id=execution_data["user_id"], - workspace_id=execution_data["workspace_id"], - is_project_chat=execution_data.get("is_project_chat", False), - ) - context.add_execution_failure("system", "execution_error", str(e)) - return context - - -async def update_assistant_message_with_execution_results(message_id: UUID, chat_id: UUID, context: BatchExecutionContext, db: AsyncSession) -> None: - """Update the assistant message with execution results.""" - try: - # Get the assistant message that relates to this user message - # The assistant message should have the user message as its parent_id - filters = [Message.parent_id == message_id, Message.chat_id == chat_id, Message.user_type == UserTypeChoices.ASSISTANT.value] - stmt = select(Message).where(*filters) # type: ignore[arg-type] - result = await db.execute(stmt) - message = result.scalar_one_or_none() - - if not message: - log.error(f"Assistant message for user message ID {message_id} not found") - return - - # Frontend now gets execution details via structured data in chat history - # No longer appending execution results to assistant message content - - except Exception as e: - log.error(f"Error updating assistant message with execution results: {e}") - if db: - await db.rollback() - - -def format_execution_response(context: BatchExecutionContext) -> Dict[str, Any]: - """Format the execution response with clean, non-redundant structure.""" - try: - # Base response structure - response: Dict[str, Any] = { - "action_summary": { - "total_planned": context.total_planned, - "completed": context.completed_count, - "failed": context.failed_count, - "duration_seconds": round((datetime.datetime.utcnow() - context.start_time).total_seconds(), 2), - }, - } - - # Add actions with consolidated entity information - if context.executed_actions: - response["actions"] = create_clean_actions_response(context.executed_actions) - - return response - - except Exception as e: - log.error(f"Error formatting execution response: {e}") - return {"action_summary": {"total_planned": 0, "completed": 0, "failed": 0, "duration_seconds": 0.0}, "actions": []} - - -def create_clean_actions_response(executed_actions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Create clean action results without duplicate entity information. - - Filters out retrieval/search tools and only includes actual modifying actions. - """ - - clean_actions = [] - - for action in executed_actions: - # Extract action from tool name (e.g., "workitems_create" -> "create") - tool_name = action.get("tool_name", "") - - # Filter out retrieval tools - only show actual actions to frontend - if tool_name: - is_retrieval, is_action = classify_tool(tool_name) - if is_retrieval and not is_action: - log.debug(f"Filtering out retrieval tool from actions response: {tool_name}") - continue - - action_name = "unknown" - if tool_name and "_" in tool_name: - action_name = tool_name.split("_", 1)[1] # Get everything after the first underscore - elif tool_name: - action_name = tool_name - - action_data = { - "action": action_name, - "artifact_type": action.get("artifact_type"), - "success": action.get("success"), - "executed_at": action.get("executed_at"), - "artifact_id": action.get("artifact_id"), # NEW: Include artifact ID - "sequence": action.get("sequence"), # NEW: Include planned step order - "version_number": action.get("version_number"), # NEW: Include version sequence number - } - - if action.get("success"): - # For successful actions, include essential entity info - entity_info = action.get("entity_info") - if entity_info and isinstance(entity_info, dict): - # Only include the most important entity fields - essential_entity = {} - for field in ["entity_url", "entity_name", "entity_type", "entity_id"]: - if field in entity_info and entity_info[field]: - essential_entity[field] = entity_info[field] - - # Include issue_identifier when available (for work-items) - if entity_info.get("issue_identifier"): - essential_entity["issue_identifier"] = entity_info["issue_identifier"] - - if essential_entity: - action_data["entity"] = essential_entity - - # Add project_identifier at the action root when derivable - project_identifier = None - try: - # Prefer deriving from identifiers present in entity_info - if entity_info.get("entity_type") == "project": - project_identifier = entity_info.get("entity_identifier") or entity_info.get("project_identifier") - elif entity_info.get("issue_identifier") and isinstance(entity_info.get("issue_identifier"), str): - ident = entity_info.get("issue_identifier") - if "-" in ident: - project_identifier = ident.split("-", 1)[0] - elif entity_info.get("entity_url") and isinstance(entity_info.get("entity_url"), str): - url = entity_info.get("entity_url") - # Attempt to parse /browse/PROJECT-SEQ/ pattern - if "/browse/" in url: - after = url.split("/browse/", 1)[1] - ident = after.split("/", 1)[0] - if "-" in ident: - project_identifier = ident.split("-", 1)[0] - except Exception: - project_identifier = None - - if project_identifier: - action_data["project_identifier"] = project_identifier - - # Extract the nice success message from the result - result = action.get("result", "") - if result: - # Extract the nice message that comes after "✅ " and before "\n\n" - nice_message = extract_success_message(result) - if nice_message: - action_data["message"] = nice_message - else: - # Fallback to generic messages - if "created" in result.lower(): - action_data["message"] = "Created successfully" - elif "updated" in result.lower(): - action_data["message"] = "Updated successfully" - else: - action_data["message"] = "Action completed successfully" - else: - # For failed actions, include error message - error = action.get("error", "") - if error: - action_data["error"] = error.split("\n")[0] - - clean_actions.append(action_data) - - return clean_actions - - -def extract_success_message(result: str) -> str: - """Extract the nice success message from the tool result.""" - if not result or not isinstance(result, str): - return "" - - # Look for the pattern: "✅ [message]\n\n" - if "✅" in result: - lines = result.split("\n") - for line in lines: - line = line.strip() - if line.startswith("✅"): - # Remove the "✅ " prefix and return the message - message = line[2:].strip() # Remove "✅ " (2 characters) - return message - - return "" - - -def get_status_message(context: BatchExecutionContext) -> str: - """Generate a user-friendly status message.""" - if context.status == "success": - return f"Successfully executed {context.completed_count} actions" - elif context.status == "partial": - return f"Partial success: {context.completed_count} actions completed, {context.failed_count} failed" - else: - return f"Batch execution failed: {context.failed_count} actions failed" diff --git a/apps/pi/pi/app/api/v1/helpers/execution.py b/apps/pi/pi/app/api/v1/helpers/execution.py new file mode 100644 index 0000000000..457e48ac5a --- /dev/null +++ b/apps/pi/pi/app/api/v1/helpers/execution.py @@ -0,0 +1,29 @@ +from typing import Optional + +from pydantic import UUID4 +from sqlmodel.ext.asyncio.session import AsyncSession + +from pi import logger +from pi.app.api.v1.helpers.message import _get_message + +log = logger.getChild(__name__) + + +async def chosen_llm(db: AsyncSession, message_id: UUID4) -> Optional[str]: + """ + Retrieve the LLM model chosen for a specific message. + + Args: + db: Database session + message_id: UUID of the message to retrieve the LLM model from + + Returns: + The LLM model string if found, None otherwise + """ + message = await _get_message(db=db, message_id=message_id) + if message: + log.info(f"Chosen LLM model for message {message_id}: {message.llm_model}") + return message.llm_model + + log.warning(f"No LLM model found for message {message_id}. Will use GPT-4.1 asdefault model.") + return None diff --git a/apps/pi/pi/app/api/v1/helpers/plane_sql_queries.py b/apps/pi/pi/app/api/v1/helpers/plane_sql_queries.py index a7f9bbbef7..e7ecfe6cb5 100644 --- a/apps/pi/pi/app/api/v1/helpers/plane_sql_queries.py +++ b/apps/pi/pi/app/api/v1/helpers/plane_sql_queries.py @@ -593,6 +593,41 @@ async def search_module_by_name(name: str, project_id: Optional[str] = None, wor return None +async def search_type_by_name(name: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Optional[Dict[str, Any]]: + """Search for an issue type by name and return its details.""" + query = """ + SELECT it.id, it.name, pit.project_id + FROM issue_types it + JOIN project_issue_types pit ON it.id = pit.issue_type_id + JOIN projects p ON pit.project_id = p.id + JOIN workspaces w ON p.workspace_id = w.id + WHERE it.name ILIKE $1 + AND it.deleted_at IS NULL + AND pit.deleted_at IS NULL + """ + + params = [f"%{name}%"] + + if project_id: + query += " AND pit.project_id = $2" + params.append(project_id) + + if workspace_slug: + query += " AND w.slug = $3" + params.append(workspace_slug) + + query += " LIMIT 1" + + try: + result = await PlaneDBPool.fetchrow(query, tuple(params)) + if result: + return {"id": str(result["id"]), "name": result["name"], "project_id": str(result["project_id"])} + return None + except Exception as e: + log.error(f"Error searching for issue type '{name}': {e}, query: {query}, project_id: {project_id}, workspace_slug: {workspace_slug}") + return None + + async def search_project_by_name( name: str, workspace_slug: Optional[str] = None, @@ -691,15 +726,15 @@ async def search_cycle_by_name(name: str, project_id: Optional[str] = None, work return None -async def search_current_cycle(project_id: str, workspace_slug: Optional[str] = None) -> Optional[Dict[str, Any]]: - """Search for the current active cycle in a project (where today's date falls within start_date and end_date). +async def search_current_cycle(project_id: str, workspace_slug: Optional[str] = None) -> List[Dict[str, Any]]: + """Search for the current active cycles in a project (where today's date falls within start_date and end_date). Args: project_id: Project ID to search within (required) workspace_slug: Workspace slug (optional filter) Returns: - Dictionary with cycle details if found, None otherwise + List of dictionaries with cycle details, empty list if none found """ query = """ SELECT c.id, c.name, c.project_id, c.workspace_id, c.start_date, c.end_date, w.slug AS workspace_slug @@ -709,8 +744,8 @@ async def search_current_cycle(project_id: str, workspace_slug: Optional[str] = WHERE c.project_id = $1 AND c.deleted_at IS NULL AND c.archived_at IS NULL - AND c.start_date <= CURRENT_DATE - AND c.end_date >= CURRENT_DATE + AND c.start_date::date <= CURRENT_DATE + AND c.end_date::date >= CURRENT_DATE """ params = [project_id] @@ -721,12 +756,13 @@ async def search_current_cycle(project_id: str, workspace_slug: Optional[str] = params.append(workspace_slug) param_index += 1 - query += " ORDER BY c.start_date DESC LIMIT 1" + query += " ORDER BY c.start_date DESC" try: - result = await PlaneDBPool.fetchrow(query, tuple(params)) - if result: - return { + results = await PlaneDBPool.fetch(query, tuple(params)) + cycles = [] + for result in results: + cycles.append({ "id": str(result["id"]), "name": result["name"], "project_id": str(result["project_id"]), @@ -734,13 +770,13 @@ async def search_current_cycle(project_id: str, workspace_slug: Optional[str] = "workspace_slug": result.get("workspace_slug"), "start_date": str(result["start_date"]) if result["start_date"] else None, "end_date": str(result["end_date"]) if result["end_date"] else None, - } - return None + }) + return cycles except Exception as e: log.error( f"Error searching for current cycle in project '{project_id}': {e}, query: {query}, project_id: {project_id}, workspace_slug: {workspace_slug}" # noqa E501 ) # noqa: E501 - return None + return [] async def search_label_by_name(name: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Optional[Dict[str, Any]]: @@ -879,6 +915,7 @@ async def search_user_by_name( WHERE ({where_name}) AND wm.deleted_at IS NULL AND u.is_active IS TRUE + AND u.is_bot IS FALSE """ if workspace_slug: @@ -887,10 +924,8 @@ async def search_user_by_name( param_index += 1 query += " LIMIT 20" - log.info(f"Searching for user by name query: {query}, params: {params}") try: rows = await PlaneDBPool.fetch(query, tuple(params)) - log.info(f"Searching for user by name rows: {rows}") return [ { "id": str(r["id"]), diff --git a/apps/pi/pi/app/api/v2/endpoints/actions.py b/apps/pi/pi/app/api/v2/endpoints/actions.py index 12d9077a84..44158121a2 100644 --- a/apps/pi/pi/app/api/v2/endpoints/actions.py +++ b/apps/pi/pi/app/api/v2/endpoints/actions.py @@ -5,15 +5,19 @@ from sqlmodel.ext.asyncio.session import AsyncSession from pi import logger from pi.app.api.v2.dependencies import cookie_schema -from pi.app.api.v2.helpers.batch_execution_helpers import execute_batch_actions -from pi.app.api.v2.helpers.batch_execution_helpers import format_execution_response -from pi.app.api.v2.helpers.batch_execution_helpers import prepare_execution_data -from pi.app.api.v2.helpers.batch_execution_helpers import update_assistant_message_with_execution_results -from pi.app.api.v2.helpers.batch_execution_helpers import validate_session_and_get_user +from pi.app.api.v2.dependencies import is_valid_session + +# from pi.services.chat.helpers.action_execution_helpers import execute_batch_actions +# from pi.services.chat.helpers.action_execution_helpers import format_execution_response +# from pi.services.chat.helpers.action_execution_helpers import prepare_execution_data +# from pi.services.chat.helpers.action_execution_helpers import update_assistant_message_with_execution_results +# from pi.services.chat.helpers.action_execution_helpers import validate_session_and_get_user from pi.app.schemas.chat import ActionBatchExecutionRequest from pi.core.db.plane_pi.lifecycle import get_async_session -from pi.services.chat.helpers.batch_execution_helpers import get_original_user_query -from pi.services.chat.helpers.batch_execution_helpers import get_planned_actions_for_execution +from pi.services.chat.action_executor import BuildModeToolExecutor +from pi.services.chat.chat import PlaneChatBot + +# from pi.services.chat.helpers.action_execution_helpers import get_original_user_query log = logger.getChild("v2.actions") router = APIRouter() @@ -120,52 +124,31 @@ async def execute_batch_actions_endpoint( """ try: # Validate session and get user - user_id = await validate_session_and_get_user(session) - if not user_id: - return JSONResponse( - status_code=401, - content={"detail": BATCH_EXECUTION_ERRORS["INVALID_SESSION"]}, - ) + auth = await is_valid_session(session) + if not auth.user: + return JSONResponse(status_code=401, content={"detail": "Invalid User"}) - # Validate and prepare execution data - execution_data = await prepare_execution_data(request, user_id, db) - if not execution_data: - # Determine the specific error based on what failed - if not await get_planned_actions_for_execution(request.message_id, request.chat_id, db): - return JSONResponse( - status_code=404, - content={"detail": BATCH_EXECUTION_ERRORS["NO_PLANNED_ACTIONS"]}, - ) - elif not await get_original_user_query(request.message_id, db): - return JSONResponse( - status_code=404, - content={"detail": BATCH_EXECUTION_ERRORS["NO_ORIGINAL_QUERY"]}, - ) - else: - # OAuth or workspace issue - return JSONResponse( - status_code=401, - content={ - "detail": BATCH_EXECUTION_ERRORS["OAUTH_REQUIRED"], - "error_code": "OAUTH_REQUIRED", - "workspace_id": str(request.workspace_id), - "user_id": str(user_id), - }, - ) + build_mode_tool_executor = BuildModeToolExecutor(chatbot=PlaneChatBot("gpt-4.1"), db=db) + result = await build_mode_tool_executor.execute(request, auth.user.id) - # Execute batch actions - context = await execute_batch_actions(execution_data, db) + # Check if service returned an error + if result.get("error"): + status_code = result.get("status_code", 500) + detail = result.get("detail", "Unknown error") - # Update the assistant message with execution results - await update_assistant_message_with_execution_results( - request.message_id, - request.chat_id, - context, - db, - ) + # Build response content + content = {"detail": detail} + if "error_code" in result: + content["error_code"] = result["error_code"] + if "workspace_id" in result: + content["workspace_id"] = result["workspace_id"] + if "user_id" in result: + content["user_id"] = result["user_id"] - # Return appropriate response based on execution status - return JSONResponse(content=format_execution_response(context)) + return JSONResponse(status_code=status_code, content=content) + + # Return successful response + return JSONResponse(content=result) except Exception as e: log.error(f"Error in execute_batch_actions: {str(e)}") @@ -173,3 +156,148 @@ async def execute_batch_actions_endpoint( status_code=500, content={"detail": BATCH_EXECUTION_ERRORS["INTERNAL_ERROR"]}, ) + + +# async def execute_batch_actions_endpoint( +# request: ActionBatchExecutionRequest, +# db: AsyncSession = Depends(get_async_session), +# session: str = Depends(cookie_schema), +# ): +# """ +# Execute all planned actions in a message as a batch using LLM orchestration. + +# This endpoint takes planned actions from an AI response and executes them in batch +# using the Plane SDK. The execution results are stored and associated with the +# assistant message for context in future conversations. + +# Execution Status Tracking: +# - When actions are planned: marked with is_executed=False in MessageFlowStep +# - When this endpoint is called: actions are marked with is_executed=True +# - Assistant messages are updated with execution results and entity information +# - Conversation history includes explicit text about executed vs. not executed actions + +# This ensures complete context for follow-up questions and LLM understanding. + +# Args: +# request: ActionBatchExecutionRequest containing: +# - workspace_id: UUID of workspace +# - chat_id: UUID of chat +# - message_id: UUID of message with planned actions +# - artifact_data: List of artifacts with action data +# - access_token: Optional OAuth token for API calls +# db: Database session (injected) +# session: Session cookie for authentication (injected) + +# Returns: +# JSON response with: +# - execution_results: List of executed action results +# - errors: List of any errors encountered +# - summary: Execution summary statistics + +# Status Codes: +# - 200: Actions executed successfully (even if some failed) +# - 401: Invalid authentication or OAuth required +# - 404: Message or planned actions not found +# - 500: Internal server error + +# Example Request: +# POST /api/v2/actions/execute +# { +# "workspace_id": "abc-123", +# "chat_id": "chat-456", +# "message_id": "msg-789", +# "artifact_data": [ +# { +# "artifact_id": "artifact-001", +# "is_edited": false, +# "action_data": {...} +# } +# ] +# } + +# Example Response: +# { +# "execution_results": [ +# { +# "action": "create_issue", +# "status": "success", +# "entity_id": "issue-123", +# "entity_name": "New bug report" +# } +# ], +# "errors": [], +# "summary": { +# "total": 1, +# "successful": 1, +# "failed": 0 +# } +# } + +# Notes: +# - Requires valid OAuth token for the workspace +# - Actions are executed in the order provided +# - Execution results are stored in the database +# - Failed actions don't stop execution of remaining actions +# - Deprecated V1 endpoint: POST /api/v1/chat/execute-action/ + +# Use Cases: +# - Execute issue creation/updates planned by AI +# - Execute project/cycle/module management actions +# - Execute bulk operations on multiple entities +# - Apply AI-suggested changes to workspace +# """ +# try: +# # Validate session and get user +# user_id = await validate_session_and_get_user(session) +# if not user_id: +# return JSONResponse( +# status_code=401, +# content={"detail": BATCH_EXECUTION_ERRORS["INVALID_SESSION"]}, +# ) + +# # Validate and prepare execution data +# execution_data = await prepare_execution_data(request, user_id, db) +# if not execution_data: +# # Determine the specific error based on what failed +# if not await get_planned_actions_for_execution(request.message_id, request.chat_id, db): +# return JSONResponse( +# status_code=404, +# content={"detail": BATCH_EXECUTION_ERRORS["NO_PLANNED_ACTIONS"]}, +# ) +# elif not await get_original_user_query(request.message_id, db): +# return JSONResponse( +# status_code=404, +# content={"detail": BATCH_EXECUTION_ERRORS["NO_ORIGINAL_QUERY"]}, +# ) +# else: +# # OAuth or workspace issue +# return JSONResponse( +# status_code=401, +# content={ +# "detail": BATCH_EXECUTION_ERRORS["OAUTH_REQUIRED"], +# "error_code": "OAUTH_REQUIRED", +# "workspace_id": str(request.workspace_id), +# "user_id": str(user_id), +# }, +# ) + +# # Execute batch actions +# context = await execute_batch_actions(execution_data, db) + +# # Update the assistant message with execution results +# await update_assistant_message_with_execution_results( +# request.message_id, +# request.chat_id, +# context, +# db, +# ) + +# # Return appropriate response based on execution status +# return JSONResponse(content=format_execution_response(context)) + +# except Exception as e: +# log.error(f"Error in execute_batch_actions: {str(e)}") +# return JSONResponse( +# status_code=500, +# content={"detail": BATCH_EXECUTION_ERRORS["INTERNAL_ERROR"]}, +# ) diff --git a/apps/pi/pi/app/api/v2/endpoints/responses.py b/apps/pi/pi/app/api/v2/endpoints/responses.py index c923e6cb69..9edb9b3dec 100644 --- a/apps/pi/pi/app/api/v2/endpoints/responses.py +++ b/apps/pi/pi/app/api/v2/endpoints/responses.py @@ -22,9 +22,6 @@ from pi import logger from pi.app.api.v1.endpoints._sse import normalize_error_chunk from pi.app.api.v1.endpoints._sse import sse_done from pi.app.api.v1.endpoints._sse import sse_event -from pi.app.api.v1.helpers.batch_execution_helpers import execute_batch_actions -from pi.app.api.v1.helpers.batch_execution_helpers import format_execution_response -from pi.app.api.v1.helpers.batch_execution_helpers import prepare_execution_data from pi.app.api.v1.helpers.plane_sql_queries import resolve_workspace_id_from_project_id from pi.app.api.v2.dependencies import cookie_schema from pi.app.api.v2.dependencies import is_valid_session @@ -40,9 +37,8 @@ from pi.app.utils.background_tasks import schedule_chat_search_upsert from pi.app.utils.exceptions import SQLGenerationError from pi.core.db.plane_pi.lifecycle import get_async_session from pi.core.db.plane_pi.lifecycle import get_streaming_db_session +from pi.services.chat.action_executor import BuildModeToolExecutor from pi.services.chat.chat import PlaneChatBot -from pi.services.chat.helpers.batch_execution_helpers import get_original_user_query -from pi.services.chat.helpers.batch_execution_helpers import get_planned_actions_for_execution from pi.services.chat.helpers.tool_utils import format_clarification_as_text from pi.services.chat.utils import resolve_workspace_slug from pi.services.retrievers.pg_store.message import get_message_by_id @@ -114,8 +110,11 @@ async def create_response_slack(data: ChatRequest, request: Request, db: AsyncSe # Listen to all the stream chunks, join the chunks and return the complete response as JSON async with get_streaming_db_session() as stream_db: - base_iter = chatbot.process_query_stream(data, db=stream_db) + base_iter = chatbot.process_chat_stream(data, db=stream_db) async for chunk in base_iter: + if isinstance(chunk, dict): + # Currently only reasoning chunk is sent as dict. + continue # Ignore all intermediate chunks if chunk.startswith("πspecial reasoning block"): continue @@ -157,7 +156,10 @@ async def create_response_slack(data: ChatRequest, request: Request, db: AsyncSe else: return JSONResponse(status_code=400, content={"detail": "Invalid artifact_id format"}) - execution_data = await prepare_execution_data( + # Execute batch actions using the service + service = BuildModeToolExecutor(chatbot=PlaneChatBot("gpt-4.1"), db=db) + + result = await service.execute( ActionBatchExecutionRequest( workspace_id=workspace_id, chat_id=chat_id, @@ -166,29 +168,23 @@ async def create_response_slack(data: ChatRequest, request: Request, db: AsyncSe access_token=access_token, ), user_id, - db, ) - if not execution_data: - # Determine the specific error based on what failed - if not await get_planned_actions_for_execution(message_id, chat_id, db): - return JSONResponse(status_code=404, content={"detail": BATCH_EXECUTION_ERRORS["NO_PLANNED_ACTIONS"]}) - elif not await get_original_user_query(message_id, db): - return JSONResponse(status_code=404, content={"detail": BATCH_EXECUTION_ERRORS["NO_ORIGINAL_QUERY"]}) - else: - # OAuth or workspace issue - return JSONResponse( - status_code=401, - content={ - "detail": BATCH_EXECUTION_ERRORS["OAUTH_REQUIRED"], - "error_code": "OAUTH_REQUIRED", - "workspace_id": str(data.workspace_id), - "user_id": str(data.user_id), - }, - ) - # Execute batch actions - context = await execute_batch_actions(execution_data, db) - formatted_context = format_execution_response(context) + # Check if service returned an error + if result.get("error"): + status_code = result.get("status_code", 500) + detail = result.get("detail", "Unknown error") + content = {"detail": detail} + if "error_code" in result: + content["error_code"] = result["error_code"] + if "workspace_id" in result: + content["workspace_id"] = result["workspace_id"] + if "user_id" in result: + content["user_id"] = result["user_id"] + return JSONResponse(status_code=status_code, content=content) + + # Extract the formatted context from successful result + formatted_context = result return JSONResponse( status_code=200, @@ -267,7 +263,7 @@ async def create_response_stream(data: ChatRequest, session: str = Depends(cooki with contextlib.suppress(asyncio.CancelledError): await asyncio.sleep(10) - base_iter = chatbot.process_query_stream(data, db=stream_db) + base_iter = chatbot.process_chat_stream(data, db=stream_db) next_chunk_task: asyncio.Task[str] = asyncio.create_task(cast(Coroutine[None, None, str], base_iter.__anext__())) # Start initial heartbeat timer diff --git a/apps/pi/pi/app/api/v2/helpers/batch_execution_helpers.py b/apps/pi/pi/app/api/v2/helpers/batch_execution_helpers.py deleted file mode 100644 index 398cb557c2..0000000000 --- a/apps/pi/pi/app/api/v2/helpers/batch_execution_helpers.py +++ /dev/null @@ -1,624 +0,0 @@ -""" -Helper functions for batch action execution in chat endpoints. -""" - -import datetime -import logging -from typing import Any -from typing import Dict -from typing import List -from typing import Optional -from typing import Set -from uuid import UUID - -from sqlalchemy import select -from sqlmodel.ext.asyncio.session import AsyncSession - -from pi.app.models.enums import UserTypeChoices -from pi.app.models.message import Message -from pi.app.schemas.chat import ActionBatchExecutionRequest -from pi.app.schemas.chat import ArtifactData -from pi.services.chat.chat import PlaneChatBot -from pi.services.chat.helpers.batch_action_orchestrator import BatchActionOrchestrator -from pi.services.chat.helpers.batch_execution_context import BatchExecutionContext -from pi.services.chat.helpers.batch_execution_helpers import get_planned_actions_for_execution -from pi.services.chat.helpers.tool_utils import classify_tool -from pi.services.retrievers.pg_store.action_artifact import get_action_artifacts_by_ids - -log = logging.getLogger(__name__) - -# Registry to handle project/workspace id for entities during manual edits. -# HOW IT WORKS: -# 1. During planning, if project_id is missing, user gets clarification prompt -# 2. For workspace-scope entities, clarification includes "Workspace level" option -# 3. User selects either "Workspace level" or a specific project -# 4. Artifact stores project_id as either "__workspace_scope__" or a UUID -# 5. During execution, the tool checks project_id and routes accordingly: -# - "__workspace_scope__" → calls workspace-level API (e.g., create_workspace_page) -# - UUID → calls project-level API (e.g., create_project_page) -# -# EDITED ARTIFACTS: -# When user edits an artifact, the resolved project_id must be preserved. -# The _extract_project_id_from_artifact() helper extracts it from planning data. -WORKSPACE_SCOPE_ENTITIES: Set[str] = { - "page", # Pages: workspace wiki OR project pages -} - - -def _is_valid_uuid(uuid_string: str) -> bool: - """Check if a string is a valid UUID format.""" - try: - UUID(uuid_string) - return True - except (ValueError, TypeError): - return False - - -def _extract_project_id_from_artifact(artifact_data: Dict[str, Any]) -> Optional[str]: - """Extract project_id from artifact data, checking multiple possible locations. - - This handles the various ways project_id can be stored in artifact data: - 1. In tool_args (most direct) - 2. In planning_data.parameters (from planning phase) - 3. In planning_data.parameters.project.id (nested structure) - - Returns: - project_id as string (can be UUID or "__workspace_scope__"), or None if not found - """ - # Try tool_args first (most direct) - tool_args = artifact_data.get("tool_args", {}) - if isinstance(tool_args, dict) and tool_args.get("project_id"): - return str(tool_args["project_id"]) - - # Try planning_data structure - planning_data = artifact_data.get("planning_data", {}) - if isinstance(planning_data, dict): - parameters = planning_data.get("parameters", {}) - if isinstance(parameters, dict): - # Direct project_id in parameters - if parameters.get("project_id"): - return str(parameters["project_id"]) - - # Nested project object with id - project_obj = parameters.get("project") - if isinstance(project_obj, dict) and project_obj.get("id"): - return str(project_obj["id"]) - - return None - - -def _extract_tool_args_from_artifact_data(artifact_data: dict, entity_type: str) -> dict: - """ - Extract and format tool arguments from artifact data for execution. - - Handles different data formats: - 1. Direct tool_args (from planning phase) - 2. Nested parameters structure (from planning_data) - 3. Flattened parameters (from UI edits) - """ - try: - # First, try direct tool_args (most reliable) - if "tool_args" in artifact_data: - tool_args = artifact_data["tool_args"] - if isinstance(tool_args, dict) and tool_args: - return tool_args - - # Next, try planning_data.parameters structure - if "planning_data" in artifact_data: - planning_data = artifact_data["planning_data"] - if isinstance(planning_data, dict): - parameters = planning_data.get("parameters", {}) - if isinstance(parameters, dict): - # Check for nested entity structure - if entity_type in parameters: - entity_params = parameters[entity_type] - if isinstance(entity_params, dict): - # Flatten entity parameters and merge with other parameters - flattened = dict(entity_params) - # Add other non-entity parameters - for key, value in parameters.items(): - if key != entity_type: - flattened[key] = value - return flattened - else: - # Parameters are already flattened - return parameters - - # Fallback: return empty dict if no valid args found - log.warning(f"No valid tool args found in artifact data for entity {entity_type}") - return {} - - except Exception as e: - log.error(f"Error extracting tool args from artifact data: {e}") - return {} - - -async def validate_session_and_get_user(session: str) -> Optional[UUID]: - """Validate session and return user ID if valid.""" - try: - from pi.app.api.v2.dependencies import is_valid_session - - auth = await is_valid_session(session) - if not auth.user: - return None - return auth.user.id - except Exception as e: - log.error(f"Error validating session: {e!s}") - return None - - -async def prepare_execution_data(request: ActionBatchExecutionRequest, user_id: UUID, db: AsyncSession) -> Optional[dict]: - """Prepare execution data and return error response if validation fails.""" - try: - # **ARTIFACT-BASED EXECUTION** - # Prefer explicit artifact_data from the client. If absent, fall back to executing - # all planned artifacts for this message to maintain backward compatibility. - artifact_items = request.artifact_data or [] - - if not artifact_items: - log.info("No artifact_data provided in request; attempting fallback to planned actions") - planned_for_message = await get_planned_actions_for_execution(request.message_id, request.chat_id, db) - if not planned_for_message: - log.error("No artifact_data provided and no planned actions found") - return None - - # Build a default list of artifacts to execute (all planned, unedited) - tmp_items: list[ArtifactData] = [] - for pa in planned_for_message: - aid = pa.get("artifact_id") - if not aid: - continue - try: - tmp_items.append(ArtifactData(artifact_id=UUID(aid), is_edited=False)) - except Exception as e: - log.error(f"Invalid artifact_id in planned actions: {aid}: {e}") - - if not tmp_items: - log.error("No valid artifact IDs found in planned actions for fallback execution") - return None - - artifact_items = tmp_items - - log.info(f"Processing artifacts: {len(artifact_items)} artifacts") - - planned_actions: List[Dict[str, Any]] = [] - - for artifact_item in artifact_items: - log.info(f"Processing artifact: {artifact_item.artifact_id} (is_edited: {artifact_item.is_edited})") - - # Get artifact metadata - artifacts = await get_action_artifacts_by_ids(db, [artifact_item.artifact_id]) - if not artifacts: - log.error(f"Artifact {artifact_item.artifact_id} not found") - continue - - artifact = artifacts[0] - entity_type = artifact.entity - entity_id = artifact.entity_id - original_action = artifact.action - - if artifact_item.is_edited: - # **EDITED ARTIFACT** - Create ActionArtifactVersion and use its UUID - - if not artifact_item.action_data: - log.error(f"Artifact {artifact_item.artifact_id} marked as edited but no action_data provided") - continue - - # For edited artifacts, use the provided action_data directly - tool_args = artifact_item.action_data.copy() - current_project_id = tool_args.get("project_id") - - # Check if project_id is missing or invalid (needs to be preserved from original) - needs_project_id = ( - not current_project_id - or current_project_id == "NEEDS_CLARIFICATION" - or (isinstance(current_project_id, str) and current_project_id.startswith(" 0 and edited_count > 0: - original_query = f"Mixed execution: {normal_count} normal + {edited_count} edited artifacts" - elif edited_count > 0: - original_query = f"Execute {edited_count} edited artifacts" - else: - original_query = f"Execute {normal_count} artifacts" - - # Get OAuth token for the user - chatbot = PlaneChatBot() - access_token = request.access_token or await chatbot._get_oauth_token_for_user(db, str(user_id), str(request.workspace_id)) - - if not access_token: - log.error(f"No valid OAuth token found for user {user_id} and workspace {request.workspace_id}") - return None - - # Get workspace context - from pi.app.api.v1.helpers.plane_sql_queries import get_workspace_slug - - workspace_slug = await get_workspace_slug(str(request.workspace_id)) - - if not workspace_slug: - return None - - # Get is_project_chat from chat record (with explicit commit/refresh to avoid race condition) - from sqlalchemy import select - - from pi.app.models.chat import Chat - - # Refresh the session to ensure we see any committed chat records - await db.commit() - - stmt = select(Chat).where(Chat.id == request.chat_id) # type: ignore[arg-type] - result = await db.execute(stmt) - chat = result.scalar_one_or_none() - is_project_chat = chat.is_project_chat if chat else False - - log.info(f"🔧 EXECUTION DATA PREP: chat_id={request.chat_id}, is_project_chat from DB (after commit)={is_project_chat}") - - # Return successful execution data - return { - "planned_actions": planned_actions, - "original_query": original_query, - "access_token": access_token, - "workspace_slug": workspace_slug, - "user_id": user_id, - "workspace_id": request.workspace_id, - "message_id": request.message_id, - "chat_id": request.chat_id, - "is_project_chat": is_project_chat, - } - except Exception as e: - log.error(f"Error preparing execution data: {e}") - return None - - -async def execute_batch_actions(execution_data: dict, db: AsyncSession) -> BatchExecutionContext: - """Execute batch actions using the orchestrator.""" - try: - # Create execution context - context = BatchExecutionContext( - message_id=execution_data["message_id"], - chat_id=execution_data["chat_id"], - user_id=execution_data["user_id"], - workspace_id=execution_data["workspace_id"], - is_project_chat=execution_data.get("is_project_chat", False), - ) - - # Create orchestrator and execute batch - chatbot = PlaneChatBot("gpt-4.1", execution_data["access_token"]) - orchestrator = BatchActionOrchestrator(chatbot, db) - - context = await orchestrator.execute_planned_actions( - context, - execution_data["original_query"], - execution_data["planned_actions"], - execution_data["access_token"], - execution_data["workspace_slug"], - ) - - return context - - except Exception as e: - log.error(f"Error executing batch actions: {e}") - # Create a failed context to return - context = BatchExecutionContext( - message_id=execution_data["message_id"], - chat_id=execution_data["chat_id"], - user_id=execution_data["user_id"], - workspace_id=execution_data["workspace_id"], - is_project_chat=execution_data.get("is_project_chat", False), - ) - context.add_execution_failure("system", "execution_error", str(e)) - return context - - -async def update_assistant_message_with_execution_results(message_id: UUID, chat_id: UUID, context: BatchExecutionContext, db: AsyncSession) -> None: - """Update the assistant message with execution results.""" - try: - # Get the assistant message that relates to this user message - # The assistant message should have the user message as its parent_id - filters = [Message.parent_id == message_id, Message.chat_id == chat_id, Message.user_type == UserTypeChoices.ASSISTANT.value] - stmt = select(Message).where(*filters) # type: ignore[arg-type] - result = await db.execute(stmt) - message = result.scalar_one_or_none() - - if not message: - log.error(f"Assistant message for user message ID {message_id} not found") - return - - # Frontend now gets execution details via structured data in chat history - # No longer appending execution results to assistant message content - - except Exception as e: - log.error(f"Error updating assistant message with execution results: {e}") - if db: - await db.rollback() - - -def format_execution_response(context: BatchExecutionContext) -> Dict[str, Any]: - """Format the execution response with clean, non-redundant structure.""" - try: - # Base response structure - response: Dict[str, Any] = { - "action_summary": { - "total_planned": context.total_planned, - "completed": context.completed_count, - "failed": context.failed_count, - "duration_seconds": round((datetime.datetime.utcnow() - context.start_time).total_seconds(), 2), - }, - } - - # Add actions with consolidated entity information - if context.executed_actions: - response["actions"] = create_clean_actions_response(context.executed_actions) - - return response - - except Exception as e: - log.error(f"Error formatting execution response: {e}") - return {"action_summary": {"total_planned": 0, "completed": 0, "failed": 0, "duration_seconds": 0.0}, "actions": []} - - -def create_clean_actions_response(executed_actions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Create clean action results without duplicate entity information. - - Filters out retrieval/search tools and only includes actual modifying actions. - """ - - clean_actions = [] - - for action in executed_actions: - # Extract action from tool name (e.g., "workitems_create" -> "create") - tool_name = action.get("tool_name", "") - - # Filter out retrieval tools - only show actual actions to frontend - if tool_name: - is_retrieval, is_action = classify_tool(tool_name) - if is_retrieval and not is_action: - log.debug(f"Filtering out retrieval tool from actions response: {tool_name}") - continue - - action_name = "unknown" - if tool_name and "_" in tool_name: - action_name = tool_name.split("_", 1)[1] # Get everything after the first underscore - elif tool_name: - action_name = tool_name - - action_data = { - "action": action_name, - "artifact_type": action.get("artifact_type"), - "success": action.get("success"), - "executed_at": action.get("executed_at"), - "artifact_id": action.get("artifact_id"), # NEW: Include artifact ID - "sequence": action.get("sequence"), # NEW: Include planned step order - "version_number": action.get("version_number"), # NEW: Include version sequence number - } - - if action.get("success"): - # For successful actions, include essential entity info - entity_info = action.get("entity_info") - if entity_info and isinstance(entity_info, dict): - # Only include the most important entity fields - essential_entity = {} - for field in ["entity_url", "entity_name", "entity_type", "entity_id"]: - if field in entity_info and entity_info[field]: - essential_entity[field] = entity_info[field] - - # Include issue_identifier when available (for work-items) - if entity_info.get("issue_identifier"): - essential_entity["issue_identifier"] = entity_info["issue_identifier"] - - if essential_entity: - action_data["entity"] = essential_entity - - # Add project_identifier at the action root when derivable - project_identifier = None - try: - # Prefer deriving from identifiers present in entity_info - if entity_info.get("entity_type") == "project": - project_identifier = entity_info.get("entity_identifier") or entity_info.get("project_identifier") - elif entity_info.get("issue_identifier") and isinstance(entity_info.get("issue_identifier"), str): - ident = entity_info.get("issue_identifier") - if "-" in ident: - project_identifier = ident.split("-", 1)[0] - elif entity_info.get("entity_url") and isinstance(entity_info.get("entity_url"), str): - url = entity_info.get("entity_url") - # Attempt to parse /browse/PROJECT-SEQ/ pattern - if "/browse/" in url: - after = url.split("/browse/", 1)[1] - ident = after.split("/", 1)[0] - if "-" in ident: - project_identifier = ident.split("-", 1)[0] - except Exception: - project_identifier = None - - if project_identifier: - action_data["project_identifier"] = project_identifier - - # Extract the nice success message from the result - result = action.get("result", "") - if result: - # Extract the nice message that comes after "✅ " and before "\n\n" - nice_message = extract_success_message(result) - if nice_message: - action_data["message"] = nice_message - else: - # Fallback to generic messages - if "created" in result.lower(): - action_data["message"] = "Created successfully" - elif "updated" in result.lower(): - action_data["message"] = "Updated successfully" - else: - action_data["message"] = "Action completed successfully" - else: - # For failed actions, include error message - error = action.get("error", "") - if error: - action_data["error"] = error[:200] + "..." if len(error) > 200 else error - - clean_actions.append(action_data) - - return clean_actions - - -def extract_success_message(result: str) -> str: - """Extract the nice success message from the tool result.""" - if not result or not isinstance(result, str): - return "" - - # Look for the pattern: "✅ [message]\n\n" - if "✅" in result: - lines = result.split("\n") - for line in lines: - line = line.strip() - if line.startswith("✅"): - # Remove the "✅ " prefix and return the message - message = line[2:].strip() # Remove "✅ " (2 characters) - return message - - return "" - - -def get_status_message(context: BatchExecutionContext) -> str: - """Generate a user-friendly status message.""" - if context.status == "success": - return f"Successfully executed {context.completed_count} actions" - elif context.status == "partial": - return f"Partial success: {context.completed_count} actions completed, {context.failed_count} failed" - else: - return f"Batch execution failed: {context.failed_count} actions failed" diff --git a/apps/pi/pi/app/models/chat.py b/apps/pi/pi/app/models/chat.py index b9ea9f8f04..5114d4046a 100644 --- a/apps/pi/pi/app/models/chat.py +++ b/apps/pi/pi/app/models/chat.py @@ -12,6 +12,7 @@ from sqlalchemy.dialects.postgresql import UUID from sqlmodel import Field from pi.app.models.base import BaseModel +from pi.app.models.enums import FocusEntityType class Chat(BaseModel, table=True): @@ -34,9 +35,23 @@ class UserChatPreference(BaseModel, table=True): # Fields is_focus_enabled: bool = Field(default=True, nullable=False) + # Polymorphic focus context - replaces focus_project_id and focus_workspace_id + focus_entity_type: Optional[str] = Field(default=None, nullable=True, max_length=50) + focus_entity_id: Optional[uuid.UUID] = Field(default=None, nullable=True) + # Legacy fields - kept for backward compatibility during migration focus_project_id: Optional[uuid.UUID] = Field(default=None, nullable=True) focus_workspace_id: Optional[uuid.UUID] = Field(default=None, nullable=True) + mode: str = Field(default="ask", nullable=False, max_length=10) # "ask" or "build" user_id: uuid.UUID = Field(default=None, nullable=False) # Foreign keys chat_id: uuid.UUID = Field(sa_column=Column(UUID(as_uuid=True), ForeignKey("chats.id", name="fk_user_chat_preferences_chat_id"), nullable=False)) + + def get_focus_entity_type_enum(self) -> Optional[FocusEntityType]: + """Get focus_entity_type as enum if valid, otherwise None.""" + if self.focus_entity_type: + try: + return FocusEntityType(self.focus_entity_type) + except ValueError: + return None + return None diff --git a/apps/pi/pi/app/models/enums.py b/apps/pi/pi/app/models/enums.py index 98bea06ed2..5eee9fd472 100644 --- a/apps/pi/pi/app/models/enums.py +++ b/apps/pi/pi/app/models/enums.py @@ -76,3 +76,22 @@ class ExecutionStatus(str, Enum): def __str__(self): return self.value + + +class FocusEntityType(str, Enum): + """Entity types that can be used as focus context in chat preferences.""" + + WORKSPACE = "workspace" + PROJECT = "project" + CYCLE = "cycle" + MODULE = "module" + INITIATIVE = "initiative" + TEAMSPACE = "teamspace" + PAGE = "page" + PROJECT_PAGE = "project_page" + WIKI = "wiki" + EPIC = "epic" + WORKITEM = "workitem" + + def __str__(self): + return self.value diff --git a/apps/pi/pi/app/models/message.py b/apps/pi/pi/app/models/message.py index 5ba36c6ce1..f6e05ce7f4 100644 --- a/apps/pi/pi/app/models/message.py +++ b/apps/pi/pi/app/models/message.py @@ -33,6 +33,9 @@ class Message(BaseModel, table=True): reasoning: Optional[str] = Field(default=None, nullable=True) user_type: str = Field(sa_column=Column(String(50), nullable=False, default=UserTypeChoices.USER.value)) workspace_slug: Optional[str] = Field(default=None, nullable=True, max_length=255) + source: Optional[str] = Field( + default=None, nullable=True, max_length=50, description="Source of the chat request (e.g., 'web', 'mobile', 'api', 'agent)" + ) # Foreign keys parent_id: Optional[uuid.UUID] = Field( diff --git a/apps/pi/pi/app/schemas/chat.py b/apps/pi/pi/app/schemas/chat.py index 5b1ef69385..f6dd5f7ec0 100644 --- a/apps/pi/pi/app/schemas/chat.py +++ b/apps/pi/pi/app/schemas/chat.py @@ -3,6 +3,7 @@ from enum import Enum from typing import Any from typing import Dict from typing import List +from typing import Literal from typing import Optional from pydantic import UUID4 @@ -52,9 +53,14 @@ class ChatRequest(BaseModel): is_temp: bool workspace_in_context: bool # is_reasoning: bool = False + # New polymorphic focus context fields + focus_entity_type: Optional[str] = Field(default=None, description="Type of focus entity (workspace, project, cycle, module, etc.)") + focus_entity_id: Optional[UUID4] = Field(default=None, description="ID of the focus entity") + # Legacy fields (for backward compatibility) workspace_id: UUID4 | None = "" # type: ignore project_id: UUID4 | None = "" # type: ignore context: dict[str, Any] + mode: Literal["ask", "build"] = "ask" # ask: normal chat, build: planning mode is_project_chat: Optional[bool] = False pi_sidebar_open: Optional[bool] = False workspace_slug: Optional[str] = None @@ -69,6 +75,10 @@ class ChatInitializationRequest(BaseModel): user_id: Optional[UUID4] = None chat_id: Optional[UUID4] = None workspace_in_context: bool = False + # New polymorphic focus context fields + focus_entity_type: Optional[str] = Field(default=None, description="Type of focus entity (workspace, project, cycle, module, etc.)") + focus_entity_id: Optional[UUID4] = Field(default=None, description="ID of the focus entity") + # Legacy fields (for backward compatibility) workspace_id: UUID4 | None = None project_id: UUID4 | None = None is_project_chat: Optional[bool] = False diff --git a/apps/pi/pi/config.py b/apps/pi/pi/config.py index 74ec149de0..29c2305bf2 100644 --- a/apps/pi/pi/config.py +++ b/apps/pi/pi/config.py @@ -8,7 +8,6 @@ from typing import Optional import colorlog from dotenv import load_dotenv -from pythonjsonlogger.json import JsonFormatter load_dotenv() @@ -197,8 +196,8 @@ class LLMConfig: ) CONTEXT_OFF_TEMPERATURE: float = 0.6 OPENAI_RANDOM_SEED: int = 314 - LITE_LLM_HOST: str = field(default_factory=lambda: os.getenv("LITE_LLM_HOST", "https://litellm.plane.town")) - LITE_LLM_API_KEY: str = field(default_factory=lambda: os.getenv("LITE_LLM_API_KEY", "sk-uFlW27K1uuF9hekh13QT9A")) + LITE_LLM_HOST: str = field(default_factory=lambda: os.getenv("LITE_LLM_HOST", "")) + LITE_LLM_API_KEY: str = field(default_factory=lambda: os.getenv("LITE_LLM_API_KEY", "")) ENABLE_MODEL_VERIFICATION_LOGGING: bool = ( False # field(default_factory=lambda: os.getenv("ENABLE_MODEL_VERIFICATION_LOGGING", "false").lower() == "true") ) @@ -397,6 +396,8 @@ class Settings: @classmethod def setup_logger(cls): + handler = colorlog.StreamHandler() + # Suppress APScheduler logs below error colorlog.getLogger("apscheduler").setLevel(colorlog.ERROR) colorlog.getLogger("apscheduler.scheduler").setLevel(colorlog.ERROR) @@ -429,8 +430,6 @@ class Settings: colorlog.getLogger("ddtrace.writer").setLevel(colorlog.INFO) colorlog.getLogger("ddtrace.internal").setLevel(colorlog.INFO) colorlog.getLogger("ddtrace.internal.module").setLevel(colorlog.INFO) - colorlog.getLogger("ddtrace.internal.telemetry").setLevel(colorlog.INFO) - colorlog.getLogger("ddtrace.internal.telemetry.writer").setLevel(colorlog.INFO) colorlog.getLogger("ddtrace.internal.runtime").setLevel(colorlog.INFO) colorlog.getLogger("ddtrace.internal.runtime.container").setLevel(colorlog.INFO) colorlog.getLogger("ddtrace._trace.processor").setLevel(colorlog.INFO) @@ -440,7 +439,10 @@ class Settings: colorlog.getLogger("datadog").setLevel(colorlog.INFO) colorlog.getLogger("datadog.dogstatsd").setLevel(colorlog.INFO) - handler = colorlog.StreamHandler() + colorlog.getLogger("ddtrace.internal.telemetry").setLevel(colorlog.INFO) + colorlog.getLogger("ddtrace.internal.telemetry.writer").setLevel(colorlog.INFO) + + 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) diff --git a/apps/pi/pi/services/actions/category_selector.py b/apps/pi/pi/services/actions/category_selector.py index 95eecd6efa..5fe8c0c148 100644 --- a/apps/pi/pi/services/actions/category_selector.py +++ b/apps/pi/pi/services/actions/category_selector.py @@ -57,5 +57,4 @@ class CategorySelector: if any(keyword in intent_lower for keyword in ["page", "note", "wiki", "notepad"]): return "pages" - # No default fallback - let the LLM handle ambiguous cases with NO_ACTIONS_PLANNED return "" diff --git a/apps/pi/pi/services/actions/plane_sdk_adapter.py b/apps/pi/pi/services/actions/plane_sdk_adapter.py index af7525a1a1..7c34e4dc77 100644 --- a/apps/pi/pi/services/actions/plane_sdk_adapter.py +++ b/apps/pi/pi/services/actions/plane_sdk_adapter.py @@ -1,10 +1,15 @@ """ -Plane SDK Adapter with Pydantic v1/v2 Compatibility -Provides a safe translation layer between Plane SDK (v1) and our codebase (v2) +Plane SDK Adapter for Plane Python SDK v0.2.x + +This adapter shields the rest of our codebase from SDK breaking changes by: +- Using the new resource-based PlaneClient (v0.2.x) +- Preserving existing adapter method names and return shapes +- Converting Pydantic models to plain dicts/lists + +Only this file should need changes when the SDK evolves. """ import logging -from datetime import datetime from typing import Any from typing import Dict from typing import List @@ -12,63 +17,37 @@ from typing import Optional from typing import Union from typing import cast -# Import native plane SDK with Pydantic v2 support - everything is exported from main module -# Note: Linter suggests specific submodule imports, but direct imports work fine at runtime -from plane import ApiClient # type: ignore[attr-defined] -from plane import AssetsApi # type: ignore[attr-defined] -from plane import Configuration # type: ignore[attr-defined] -from plane import CycleCreateRequest # type: ignore[attr-defined] -from plane import CycleIssueRequestRequest # type: ignore[attr-defined] -from plane import CyclesApi # type: ignore[attr-defined] -from plane import IntakeApi # type: ignore[attr-defined] -from plane import IntakeIssueCreateRequest # type: ignore[attr-defined] -from plane import IssueAttachmentUploadRequest # type: ignore[attr-defined] -from plane import IssueCommentCreateRequest # type: ignore[attr-defined] -from plane import IssueLinkCreateRequest # type: ignore[attr-defined] -from plane import IssuePropertyAPIRequest # type: ignore[attr-defined] -from plane import IssuePropertyOptionAPIRequest # type: ignore[attr-defined] -from plane import IssuePropertyValueAPIRequest # type: ignore[attr-defined] -from plane import IssueRelationCreateRequest # type: ignore[attr-defined] -from plane import IssueRequest # type: ignore[attr-defined] -from plane import IssueTypeAPIRequest # type: ignore[attr-defined] -from plane import IssueWorkLogAPIRequest # type: ignore[attr-defined] -from plane import LabelCreateUpdateRequest # type: ignore[attr-defined] -from plane import LabelsApi # type: ignore[attr-defined] -from plane import MembersApi # type: ignore[attr-defined] -from plane import ModuleCreateRequest # type: ignore[attr-defined] -from plane import ModuleIssueRequestRequest # type: ignore[attr-defined] -from plane import ModulesApi # type: ignore[attr-defined] -from plane import PagesApi # type: ignore[attr-defined] -from plane import PatchedCycleUpdateRequest # type: ignore[attr-defined] -from plane import PatchedIntakeIssueUpdateRequest # type: ignore[attr-defined] -from plane import PatchedIssueCommentCreateRequest # type: ignore[attr-defined] -from plane import PatchedIssueLinkUpdateRequest # type: ignore[attr-defined] -from plane import PatchedIssuePropertyAPIRequest # type: ignore[attr-defined] -from plane import PatchedIssuePropertyOptionAPIRequest # type: ignore[attr-defined] -from plane import PatchedIssueRequest # type: ignore[attr-defined] -from plane import PatchedIssueTypeAPIRequest # type: ignore[attr-defined] -from plane import PatchedIssueWorkLogAPIRequest # type: ignore[attr-defined] -from plane import PatchedLabelCreateUpdateRequest # type: ignore[attr-defined] -from plane import PatchedModuleUpdateRequest # type: ignore[attr-defined] -from plane import PatchedProjectUpdateRequest # type: ignore[attr-defined] -from plane import PatchedStateRequest # type: ignore[attr-defined] -from plane import ProjectCreateRequest # type: ignore[attr-defined] -from plane import ProjectsApi # type: ignore[attr-defined] -from plane import StateRequest # type: ignore[attr-defined] -from plane import StatesApi # type: ignore[attr-defined] -from plane import TransferCycleIssueRequestRequest # type: ignore[attr-defined] -from plane import UsersApi # type: ignore[attr-defined] -from plane import WorkItemActivityApi # type: ignore[attr-defined] -from plane import WorkItemAttachmentsApi # type: ignore[attr-defined] -from plane import WorkItemCommentsApi # type: ignore[attr-defined] -from plane import WorkItemLinksApi # type: ignore[attr-defined] -from plane import WorkItemPropertiesApi # type: ignore[attr-defined] -from plane import WorkItemsApi # type: ignore[attr-defined] -from plane import WorkItemTypesApi # type: ignore[attr-defined] -from plane import WorkItemWorklogsApi # type: ignore[attr-defined] +# New SDK (v0.2.x) +from plane.client import PlaneClient # type: ignore[attr-defined] +from plane.errors import HttpError # type: ignore[attr-defined] +from plane.models.cycles import CreateCycle # type: ignore[attr-defined] +from plane.models.cycles import UpdateCycle # type: ignore[attr-defined] +from plane.models.intake import CreateIntakeWorkItem # type: ignore[attr-defined] +from plane.models.intake import UpdateIntakeWorkItem # type: ignore[attr-defined] +from plane.models.labels import CreateLabel # type: ignore[attr-defined] +from plane.models.labels import UpdateLabel # type: ignore[attr-defined] +from plane.models.modules import CreateModule as ModulesCreateModule # type: ignore[attr-defined] +from plane.models.modules import UpdateModule as ModulesUpdateModule # type: ignore[attr-defined] +from plane.models.pages import CreatePage # type: ignore[attr-defined] -# Import page models from their specific modules (SDK >= 0.1.10) -from plane.models.page_create_api_request import PageCreateAPIRequest # type: ignore[attr-defined] +# Common models we will use immediately; add more as we migrate additional methods +from plane.models.projects import CreateProject # type: ignore[attr-defined] +from plane.models.projects import UpdateProject # type: ignore[attr-defined] +from plane.models.query_params import WorkItemQueryParams # type: ignore[attr-defined] +from plane.models.states import CreateState # type: ignore[attr-defined] +from plane.models.states import UpdateState # type: ignore[attr-defined] +from plane.models.work_item_properties import CreateWorkItemProperty # type: ignore[attr-defined] +from plane.models.work_item_properties import UpdateWorkItemProperty # type: ignore[attr-defined] +from plane.models.work_item_types import CreateWorkItemType # type: ignore[attr-defined] +from plane.models.work_item_types import UpdateWorkItemType # type: ignore[attr-defined] +from plane.models.work_items import CreateWorkItem # type: ignore[attr-defined] +from plane.models.work_items import CreateWorkItemComment # type: ignore[attr-defined] +from plane.models.work_items import CreateWorkItemLink # type: ignore[attr-defined] +from plane.models.work_items import UpdateWorkItem # type: ignore[attr-defined] +from plane.models.work_items import UpdateWorkItemAttachment # type: ignore[attr-defined] +from plane.models.work_items import UpdateWorkItemComment # type: ignore[attr-defined] +from plane.models.work_items import UpdateWorkItemLink # type: ignore[attr-defined] +from plane.models.work_items import WorkItemAttachmentUploadRequest # type: ignore[attr-defined] log = logging.getLogger(__name__) @@ -80,38 +59,16 @@ class PlaneSDKAdapter: """ def __init__(self, access_token: Optional[str] = None, api_key: Optional[str] = None, base_url: str = "https://api.plane.so"): - """Initialize SDK with proper authentication.""" - if access_token: - # OAuth Bearer token - self.configuration = Configuration(access_token=access_token, host=base_url) - elif api_key: - # API Key authentication - self.configuration = Configuration(api_key={"ApiKeyAuthentication": api_key}, host=base_url) - else: + """Initialize the v0.2 PlaneClient. base_url must not include /api/v1.""" + if not access_token and not api_key: raise ValueError("Either access_token or api_key must be provided") - # Create API client - self.api_client = ApiClient(self.configuration) + # The new client enforces exactly one of api_key/access_token. Prefer access_token if provided. + if access_token and api_key: + # Choose access_token to avoid ConfigurationError + api_key = None - # Initialize API handlers - self.assets = AssetsApi(self.api_client) - self.cycles = CyclesApi(self.api_client) - self.intake = IntakeApi(self.api_client) - self.labels = LabelsApi(self.api_client) - self.members = MembersApi(self.api_client) - self.modules = ModulesApi(self.api_client) - self.pages = PagesApi(self.api_client) - self.projects = ProjectsApi(self.api_client) - self.states = StatesApi(self.api_client) - self.users = UsersApi(self.api_client) - self.work_item_activity = WorkItemActivityApi(self.api_client) - self.work_item_attachments = WorkItemAttachmentsApi(self.api_client) - self.work_item_comments = WorkItemCommentsApi(self.api_client) - self.work_item_links = WorkItemLinksApi(self.api_client) - self.work_item_properties = WorkItemPropertiesApi(self.api_client) - self.work_item_types = WorkItemTypesApi(self.api_client) - self.work_item_worklogs = WorkItemWorklogsApi(self.api_client) - self.work_items = WorkItemsApi(self.api_client) + self.client = PlaneClient(base_url=base_url, access_token=access_token, api_key=api_key) def _model_to_dict(self, model: Any) -> Union[Dict[str, Any], List[Any], Any]: """ @@ -158,109 +115,24 @@ class PlaneSDKAdapter: # Otherwise, use the regular conversion return self._model_to_dict(model) - def create_project( - self, - slug: Optional[str] = None, - workspace_slug: Optional[str] = None, - project_create_request: Optional[ProjectCreateRequest] = None, - name: Optional[str] = None, - identifier: Optional[str] = None, - description: Optional[str] = None, - network: Optional[int] = 2, - **kwargs, - ) -> Dict[str, Any]: - """ - Create a new project and return as plain dict. - - Returns: - Dict with project data, safely converted from v1 model - """ + def _get_current_user_id(self) -> Optional[str]: + """Best-effort helper to fetch current user's id via v0.2 client.""" try: - # Canonicalize slug/workspace_slug - effective_slug = slug or workspace_slug - if not effective_slug: - raise ValueError("slug (workspace_slug) is required") + me = self.client.users.get_me() + # Prefer attribute access, fallback to dict conversion + if hasattr(me, "id") and getattr(me, "id"): + return str(getattr(me, "id")) + me_dict = self._model_to_dict(me) + if isinstance(me_dict, dict): + uid = me_dict.get("id") or me_dict.get("user_id") + return str(uid) if uid else None + except Exception: + pass + return None - # Build or use provided request object - if project_create_request is not None: - project_request = project_create_request - else: - if not name or not identifier: - raise ValueError("name and identifier are required when project_create_request is not provided") - project_request = ProjectCreateRequest(name=name, identifier=identifier, description=description or "", **kwargs) - - # Call SDK method - project = self.projects.create_project(slug=effective_slug, project_create_request=project_request) - - # Convert to dict before returning - result = self._model_to_dict(project) - if isinstance(result, dict): - return result - else: - # Fallback if conversion didn't return a dict - return {"data": result} - - except Exception as e: - log.error(f"Failed to create project: {str(e)}") - raise - - def list_projects( - self, slug: Optional[str] = None, workspace_slug: Optional[str] = None, per_page: Optional[int] = 20, cursor: Optional[str] = None - ) -> Dict[str, Any]: - """ - List projects and return as plain dict with pagination info. - - Returns: - Dict containing: - - results: List of project dicts - - next_cursor: Cursor for next page - - prev_cursor: Cursor for previous page - - count: Number of items - - total_results: Total count - """ - try: - # Canonicalize slug/workspace_slug - effective_slug = slug or workspace_slug - if not effective_slug: - raise ValueError("slug (workspace_slug) is required") - - # Call SDK method with correct parameter names - response = self.projects.list_projects(slug=effective_slug, per_page=per_page, cursor=cursor) - - # Build response dict matching our existing format - results_data = self._model_to_dict(response.results) - if not isinstance(results_data, list): - results_data = [results_data] if results_data else [] - - # Filter out archived and deleted projects - filtered_results = [] - for project in results_data: - if isinstance(project, dict): - # Not skip archived projects (archived_at is not None) for now. This may change in the future. - # if project.get("archived_at") is not None: - # continue - # Skip deleted projects (deleted_at is not None) - if project.get("deleted_at") is not None: - continue - filtered_results.append(project) - - result: Dict[str, Any] = { - "results": filtered_results, - "count": len(filtered_results), - "total_results": getattr(response, "count", len(results_data)), # Keep original total for pagination - } - - # Add pagination info if available - if hasattr(response, "next_cursor") and response.next_cursor: - result["next_cursor"] = str(response.next_cursor) - if hasattr(response, "prev_cursor") and response.prev_cursor: - result["prev_cursor"] = str(response.prev_cursor) - - return result - - except Exception as e: - log.error(f"Failed to list projects: {str(e)}") - raise + # ============================================================================ + # WORKITEMS API METHODS + # ============================================================================ def create_work_item( self, @@ -283,38 +155,31 @@ class PlaneSDKAdapter: Dict with work item data, safely converted from v1 model """ try: - # Build issue data - issue_data: Dict[str, Any] = { - "name": name, - "project_id": project_id, - } - - # Add optional fields - if description_html: - issue_data["description_html"] = description_html - if priority: - issue_data["priority"] = priority - if state: - issue_data["state"] = state + data: Dict[str, Any] = {"name": name, "project_id": project_id} + if description_html is not None: + data["description_html"] = description_html + if priority is not None: + data["priority"] = priority + # Map legacy "state" to new "state_id" if looks like an id + if state is not None: + data["state_id"] = state if assignees: - issue_data["assignees"] = assignees # List[str] is expected by SDK + data["assignees"] = assignees if labels: - issue_data["labels"] = labels # List[str] is expected by SDK + data["labels"] = labels if start_date: - issue_data["start_date"] = start_date + data["start_date"] = start_date if target_date: - issue_data["target_date"] = target_date + data["target_date"] = target_date + # Allow extra fields to pass-through (SDK DTO uses extra="ignore") - # Add any additional kwargs - issue_data.update(kwargs) + if kwargs: + filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None} + data.update(filtered_kwargs) - # Create the issue request object - issue_request = IssueRequest(**issue_data) + request_model = CreateWorkItem(**data) + issue = self.client.work_items.create(workspace_slug=workspace_slug, project_id=project_id, data=request_model) - # Call SDK method (note: parameter order is project_id, slug, issue_request) - issue = self.work_items.create_work_item(project_id=project_id, slug=workspace_slug, issue_request=issue_request) - - # Convert to dict before returning result = self._model_to_dict(issue) if isinstance(result, dict): return result @@ -322,6 +187,9 @@ class PlaneSDKAdapter: # Fallback if conversion didn't return a dict return {"data": result} + except HttpError as e: + log.error(f"Failed to create work item: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to create work item: {str(e)}") raise @@ -334,20 +202,20 @@ class PlaneSDKAdapter: Dict with updated work item data """ try: - # Create the patched issue request object - patched_request = PatchedIssueRequest(**update_data) + # Map legacy fields + payload = dict(update_data) + if "state" in payload and "state_id" not in payload: + payload["state_id"] = payload.pop("state") - # Log exact SDK payload - try: - body_dump = patched_request.model_dump(exclude_none=True) if hasattr(patched_request, "model_dump") else dict(update_data) - log.info(f"SDK PAYLOAD work_items.update_work_item: pk={issue_id}, project_id={project_id}, slug={workspace_slug}, body={body_dump}") - except Exception: - pass + patched = UpdateWorkItem(**payload) - # Call SDK method (note: parameter order is pk, project_id, slug, patched_issue_request) - issue = self.work_items.update_work_item(pk=issue_id, project_id=project_id, slug=workspace_slug, patched_issue_request=patched_request) + issue = self.client.work_items.update( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=issue_id, + data=patched, + ) - # Convert to dict before returning result = self._model_to_dict(issue) if isinstance(result, dict): return result @@ -355,10 +223,80 @@ class PlaneSDKAdapter: # Fallback if conversion didn't return a dict return {"data": result} + except HttpError as e: + log.error(f"Failed to update work item: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to update work item: {str(e)}") raise + def retrieve_work_item(self, workspace_slug: str, project_id: str, issue_id: str, **kwargs) -> Dict[str, Any]: + """Retrieve a work item using v0.2 client; keep return as dict.""" + try: + # Optional query params + params = None + if kwargs: + # Only include supported params + expand = kwargs.get("expand") or kwargs.get("fields") + if expand: + from plane.models.query_params import RetrieveQueryParams # type: ignore[attr-defined] + + params = RetrieveQueryParams(expand=str(expand)) + + wi = self.client.work_items.retrieve( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=issue_id, + params=params, + ) + return self._model_to_dict(wi) # type: ignore[return-value] + except HttpError as e: + log.error(f"Failed to retrieve work item: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to retrieve work item: {str(e)}") + raise + + def list_work_items(self, workspace_slug: str, project_id: str, **kwargs) -> Dict[str, Any]: + """List work items; normalize pagination to existing shape.""" + try: + params = None + if kwargs: + # Map common filters + per_page = kwargs.get("per_page") + order_by = kwargs.get("order_by") + page = kwargs.get("page") + params = WorkItemQueryParams(per_page=per_page, page=page, order_by=order_by) + + response = self.client.work_items.list(workspace_slug=workspace_slug, project_id=project_id, params=params) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list work items: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to list work items: {str(e)}") + raise + + def delete_work_item(self, workspace_slug: str, project_id: str, issue_id: str) -> Dict[str, Any]: + try: + self.client.work_items.delete(workspace_slug=workspace_slug, project_id=project_id, work_item_id=issue_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete work item: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to delete work item: {str(e)}") + raise + def create_work_item_relation( self, workspace_slug: str, @@ -367,173 +305,184 @@ class PlaneSDKAdapter: relation_type: str, issues: List[str], ) -> Dict[str, Any]: - """ - Create relationships between work items. - - Args: - workspace_slug: Workspace slug - project_id: Project ID - issue_id: Source issue ID to create relations from - relation_type: Type of relationship (blocking, blocked_by, duplicate, etc.) - issues: List of issue IDs to create relations with - - Returns: - Dict with relation data, safely converted from v1 model - """ + """Create work item relations (v0.2).""" try: - # Create the relation request object with proper parameters - # Note: SDK expects enum but accepts string at runtime - relation_request = IssueRelationCreateRequest( - relation_type=relation_type, # type: ignore[arg-type] - issues=issues, - ) - - # Call SDK method (note: parameter order is issue_id, project_id, slug, relation_request) - relations = self.work_items.create_work_item_relation( - issue_id=issue_id, project_id=project_id, slug=workspace_slug, issue_relation_create_request=relation_request - ) - - # Convert to dict before returning - # SDK always returns a list of relations - if isinstance(relations, list): - result = [self._model_to_dict(relation) for relation in relations] - return {"relations": result, "count": len(result)} - - # Fallback case (though SDK typically returns list) - fallback_result = self._model_to_dict(relations) # type: ignore[unreachable] - return {"data": fallback_result} - + payload = {"relation_type": relation_type, "issues": issues} + resp = self.client.work_items.relations.create(workspace_slug, project_id, issue_id, data=payload) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to create work item relation: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to create work item relation: {str(e)}") raise + # ============================================================================ + # USERS API METHODS + # ============================================================================ + def get_current_user(self) -> Dict[str, Any]: - """Get current authenticated user info.""" + """Get current authenticated user info (v0.2).""" try: - user = self.users.get_current_user() + user = self.client.users.get_me() result = self._model_to_dict(user) if isinstance(result, dict): return result else: # Fallback if conversion didn't return a dict return {"data": result} + except HttpError as e: + log.error(f"Failed to get current user: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to get current user: {str(e)}") raise - def create_cycle( + # ============================================================================ + # PROJECTS API METHODS + # ============================================================================ + + def create_project( self, - workspace_slug: str, - project_id: str, - name: str, - start_date: Optional[str] = None, - end_date: Optional[str] = None, + workspace_slug: Optional[str] = None, + name: Optional[str] = None, + identifier: Optional[str] = None, description: Optional[str] = None, - owned_by: Optional[str] = None, - user_id: Optional[str] = None, **kwargs, ) -> Dict[str, Any]: """ - Create a new cycle and return as plain dict. + Create a new project and return as plain dict. Returns: - Dict with cycle data, safely converted from v1 model + Dict with project data, safely converted from v1 model """ try: - if owned_by is None: - if user_id: - owned_by = user_id - # log.info(f"Using user_id from context as cycle owner: {user_id}") - else: - try: - current_user = self.get_current_user() - owned_by = current_user.get("id") - if not owned_by: - log.warning("Could not get current user ID, cycle creation may fail") - except Exception as e: - log.warning(f"Failed to get current user for cycle ownership: {e}") + if not name or not identifier: + raise ValueError("name and identifier are required") - # Prepare cycle data - cycle_data = {"name": name, **kwargs} - if start_date: - cycle_data["start_date"] = datetime.fromisoformat(start_date.replace("Z", "+00:00")) - if end_date: - cycle_data["end_date"] = datetime.fromisoformat(end_date.replace("Z", "+00:00")) - if description: - cycle_data["description"] = description - if owned_by: - cycle_data["owned_by"] = owned_by + data = { + "name": name, + "identifier": identifier, + "description": description or "", + } - # Create the request object - cycle_request = CycleCreateRequest(**cycle_data) + if kwargs: + filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None} + data.update(filtered_kwargs) - # WORKAROUND: Backend API requires project_id in body when dates are present. - # We monkey-patch model_dump() to inject project_id when needed. - if (start_date or end_date) and project_id: - original_model_dump = cycle_request.model_dump + project = self.client.projects.create(workspace_slug=workspace_slug, data=CreateProject(**data)) - def model_dump_with_project_id(*args, **kwargs): - result = original_model_dump(*args, **kwargs) - result["project_id"] = project_id - return result - - # Use object.__setattr__ to bypass Pydantic's attribute protection - object.__setattr__(cycle_request, "model_dump", model_dump_with_project_id) - - # Call SDK method - cycle = self.cycles.create_cycle(project_id=project_id, slug=workspace_slug, cycle_create_request=cycle_request) - - # Convert to dict before returning - result = self._model_to_dict(cycle) + result = self._model_to_dict(project) if isinstance(result, dict): return result else: # Fallback if conversion didn't return a dict return {"data": result} + except HttpError as e: + log.error(f"Failed to create project: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: - log.error(f"Failed to create cycle: {str(e)}") + log.error(f"Failed to create project: {str(e)}") raise - def list_cycles( - self, workspace_slug: str, project_id: str, per_page: Optional[int] = 20, cursor: Optional[str] = None, cycle_view: Optional[str] = None + def list_projects( + self, slug: Optional[str] = None, workspace_slug: Optional[str] = None, per_page: Optional[int] = 20, cursor: Optional[str] = None ) -> Dict[str, Any]: """ - List cycles and return as plain dict with pagination info. + List projects and return as plain dict with pagination info. Returns: Dict containing: - - results: List of cycle dicts + - results: List of project dicts - next_cursor: Cursor for next page - prev_cursor: Cursor for previous page - count: Number of items - total_results: Total count """ try: - # Call SDK method with correct parameter names - response = self.cycles.list_cycles(project_id=project_id, slug=workspace_slug, per_page=per_page, cursor=cursor, cycle_view=cycle_view) + effective_slug = slug or workspace_slug + if not effective_slug: + raise ValueError("slug (workspace_slug) is required") - # Build response dict matching our existing format - results_data = self._model_to_dict(response.results) + # v0.2: simple list; we ignore cursor here for now (page-based in v0.2) + response = self.client.projects.list(workspace_slug=effective_slug) + + results_data = self._model_to_dict(getattr(response, "results", [])) if not isinstance(results_data, list): results_data = [results_data] if results_data else [] + # Filter out deleted projects + filtered_results = [] + for project in results_data: + if isinstance(project, dict): + if project.get("deleted_at") is not None: + continue + filtered_results.append(project) + result: Dict[str, Any] = { - "results": results_data, - "count": len(results_data), - "total_results": getattr(response, "count", len(results_data)), + "results": filtered_results, + "count": len(filtered_results), + "total_results": getattr(response, "total_count", len(results_data)), } - # Add pagination info if available - if hasattr(response, "next_cursor") and response.next_cursor: - result["next_cursor"] = str(response.next_cursor) - if hasattr(response, "prev_cursor") and response.prev_cursor: - result["prev_cursor"] = str(response.prev_cursor) + # v0.2 exposes next_page_number/prev_page_number; we expose as next_cursor/prev_cursor best-effort + if hasattr(response, "next_page_number") and response.next_page_number is not None: + result["next_cursor"] = str(response.next_page_number) + if hasattr(response, "prev_page_number") and response.prev_page_number is not None: + result["prev_cursor"] = str(response.prev_page_number) return result + except HttpError as e: + log.error(f"Failed to list projects: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: - log.error(f"Failed to list cycles: {str(e)}") + log.error(f"Failed to list projects: {str(e)}") + raise + + def retrieve_project(self, workspace_slug: str, project_id: str) -> Dict[str, Any]: + try: + prj = self.client.projects.retrieve(workspace_slug=workspace_slug, project_id=project_id) + result = self._model_to_dict(prj) + if isinstance(result, dict): + return result + else: + # Fallback if conversion didn't return a dict + return {"data": result} + except HttpError as e: + log.error(f"Failed to retrieve project: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to retrieve project: {str(e)}") + raise + + def update_project(self, workspace_slug: str, project_id: str, **update_data) -> Dict[str, Any]: + try: + patched = UpdateProject(**update_data) + prj = self.client.projects.update(workspace_slug=workspace_slug, project_id=project_id, data=patched) + result = self._model_to_dict(prj) + if isinstance(result, dict): + return result + else: + # Fallback if conversion didn't return a dict + return {"data": result} + except HttpError as e: + log.error(f"Failed to update project: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to update project: {str(e)}") + raise + + def delete_project(self, workspace_slug: str, project_id: str) -> Dict[str, Any]: + try: + self.client.projects.delete(workspace_slug=workspace_slug, project_id=project_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete project: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to delete project: {str(e)}") raise # ============================================================================ @@ -546,7 +495,7 @@ class PlaneSDKAdapter: from plane import GenericAssetUploadRequest # type: ignore[attr-defined] request = GenericAssetUploadRequest(**kwargs) - response = self.assets.create_generic_asset_upload(slug=workspace_slug, generic_asset_upload_request=request) + response = self.client.assets.create_generic_asset_upload(slug=workspace_slug, generic_asset_upload_request=request) return self._model_to_dict(response) except Exception as e: log.error(f"Failed to create generic asset upload: {str(e)}") @@ -558,7 +507,7 @@ class PlaneSDKAdapter: from plane import UserAssetUploadRequest # type: ignore[attr-defined] request = UserAssetUploadRequest(**kwargs) - response = self.assets.create_user_asset_upload(user_asset_upload_request=request) + response = self.client.assets.create_user_asset_upload(user_asset_upload_request=request) return self._model_to_dict(response) except Exception as e: log.error(f"Failed to create user asset upload: {str(e)}") @@ -567,7 +516,7 @@ class PlaneSDKAdapter: def get_generic_asset(self, workspace_slug: str, project_id: str, asset_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: """Get a generic asset.""" try: - response = self.assets.get_generic_asset(asset_id=asset_id, slug=workspace_slug, **kwargs) + response = self.client.assets.get_generic_asset(asset_id=asset_id, slug=workspace_slug, **kwargs) return self._model_to_dict(response) except Exception as e: log.error(f"Failed to get generic asset: {str(e)}") @@ -576,7 +525,7 @@ class PlaneSDKAdapter: def update_generic_asset(self, workspace_slug: str, project_id: str, asset_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: """Update a generic asset.""" try: - response = self.assets.update_generic_asset(asset_id=asset_id, slug=workspace_slug, **kwargs) + response = self.client.assets.update_generic_asset(asset_id=asset_id, slug=workspace_slug, **kwargs) return self._model_to_dict(response) except Exception as e: log.error(f"Failed to update generic asset: {str(e)}") @@ -585,7 +534,7 @@ class PlaneSDKAdapter: def update_user_asset(self, workspace_slug: str, project_id: str, asset_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: """Update a user asset.""" try: - response = self.assets.update_user_asset(asset_id=asset_id, **kwargs) + response = self.client.assets.update_user_asset(asset_id=asset_id, **kwargs) return self._model_to_dict(response) except Exception as e: log.error(f"Failed to update user asset: {str(e)}") @@ -594,7 +543,7 @@ class PlaneSDKAdapter: def delete_user_asset(self, workspace_slug: str, project_id: str, asset_id: str) -> bool: """Delete a user asset.""" try: - self.assets.delete_user_asset(asset_id=asset_id) + self.client.assets.delete_user_asset(asset_id=asset_id) return True except Exception as e: log.error(f"Failed to delete user asset: {str(e)}") @@ -604,116 +553,164 @@ class PlaneSDKAdapter: # LABELS API METHODS # ============================================================================ - def list_labels(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List labels in a project.""" + def list_labels(self, workspace_slug: str, project_id: str) -> Dict[str, Any]: + """List labels (v0.2).""" try: - response = self.labels.list_labels(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + resp = self.client.labels.list(workspace_slug, project_id) + results = self._model_to_dict(getattr(resp, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(resp, "total_count", len(results)), + "next_cursor": str(getattr(resp, "next_page_number", "")) if getattr(resp, "next_page_number", None) else None, + "prev_cursor": str(getattr(resp, "prev_page_number", "")) if getattr(resp, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list labels: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list labels: {str(e)}") raise - def create_label(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Create a new label.""" + def create_label(self, workspace_slug: str, project_id: str, **kwargs) -> Dict[str, Any]: + """Create a label (v0.2). Requires name.""" try: - # Create the label request object - label_request = LabelCreateUpdateRequest(**kwargs) + name = kwargs.get("name") + if not name: + raise ValueError("name is required to create a label") - # Call SDK method with the request object - response = self.labels.create_label(project_id=project_id, slug=workspace_slug, label_create_update_request=label_request) - return self._model_to_dict(response) + # Build payload with all supported CreateLabel fields + payload = {"name": name} + + # Add optional fields if provided + for field in ["color", "description", "parent", "sort_order", "external_source", "external_id"]: + if field in kwargs and kwargs[field] is not None: + payload[field] = kwargs[field] + + data_model = CreateLabel(**payload) + resp = self.client.labels.create(workspace_slug, project_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to create label: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to create label: {str(e)}") raise - def update_label(self, workspace_slug: str, project_id: str, label_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Update a label.""" + def update_label(self, workspace_slug: str, project_id: str, label_id: str, **kwargs) -> Dict[str, Any]: + """Update a label (v0.2).""" try: - # Create the patched label request object - patched_label_request = PatchedLabelCreateUpdateRequest(**kwargs) - - # Call SDK method with the request object - response = self.labels.update_label( - pk=label_id, project_id=project_id, slug=workspace_slug, patched_label_create_update_request=patched_label_request - ) - return self._model_to_dict(response) + payload = {k: v for k, v in kwargs.items() if v is not None} + data_model = UpdateLabel(**payload) + resp = self.client.labels.update(workspace_slug, project_id, label_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to update label: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to update label: {str(e)}") raise - def delete_label(self, workspace_slug: str, project_id: str, label_id: str) -> bool: - """Delete a label.""" + def delete_label(self, workspace_slug: str, project_id: str, label_id: str) -> Dict[str, Any]: + """Delete a label (v0.2).""" try: - self.labels.delete_label(pk=label_id, project_id=project_id, slug=workspace_slug) - return True + self.client.labels.delete(workspace_slug, project_id, label_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete label: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to delete label: {str(e)}") raise - def get_labels(self, workspace_slug: str, project_id: str, label_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Get a specific label by ID.""" + def retrieve_label(self, workspace_slug: str, project_id: str, label_id: str) -> Dict[str, Any]: + """Retrieve a label (v0.2).""" try: - response = self.labels.get_labels(pk=label_id, project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + resp = self.client.labels.retrieve(workspace_slug, project_id, label_id) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to retrieve label: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: - log.error(f"Failed to get label: {str(e)}") + log.error(f"Failed to retrieve label: {str(e)}") raise # ============================================================================ # STATES API METHODS # ============================================================================ - def list_states(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List states in a project.""" + def list_states(self, workspace_slug: str, project_id: str) -> Dict[str, Any]: + """List states (v0.2).""" try: - response = self.states.list_states(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + resp = self.client.states.list(workspace_slug, project_id) + results = self._model_to_dict(getattr(resp, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(resp, "total_count", len(results)), + "next_cursor": str(getattr(resp, "next_page_number", "")) if getattr(resp, "next_page_number", None) else None, + "prev_cursor": str(getattr(resp, "prev_page_number", "")) if getattr(resp, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list states: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list states: {str(e)}") raise - def create_state(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Create a new state.""" + def create_state(self, workspace_slug: str, project_id: str, **kwargs) -> Dict[str, Any]: + """Create a state (v0.2). Requires name, color, group.""" try: - # Create the state request object - state_request = StateRequest(**kwargs) - - # Call SDK method with the request object - response = self.states.create_state(project_id=project_id, slug=workspace_slug, state_request=state_request) - return self._model_to_dict(response) + required = {"name", "color", "group"} + if not required.issubset(set(kwargs.keys())): + raise ValueError("name, color, group are required to create a state") + data_model = CreateState(**{k: v for k, v in kwargs.items() if v is not None}) + resp = self.client.states.create(workspace_slug, project_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to create state: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to create state: {str(e)}") raise - def update_state(self, workspace_slug: str, project_id: str, state_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Update a state.""" + def update_state(self, workspace_slug: str, project_id: str, state_id: str, **kwargs) -> Dict[str, Any]: + """Update a state (v0.2).""" try: - # Create the patched state request object - patched_state_request = PatchedStateRequest(**kwargs) - - # Call SDK method with the request object - response = self.states.update_state( - project_id=project_id, slug=workspace_slug, state_id=state_id, patched_state_request=patched_state_request - ) - return self._model_to_dict(response) + data_model = UpdateState(**{k: v for k, v in kwargs.items() if v is not None}) + resp = self.client.states.update(workspace_slug, project_id, state_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to update state: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to update state: {str(e)}") raise - def retrieve_state(self, workspace_slug: str, project_id: str, state_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Get a specific state by ID.""" + def retrieve_state(self, workspace_slug: str, project_id: str, state_id: str) -> Dict[str, Any]: + """Retrieve a state (v0.2).""" try: - response = self.states.retrieve_state(project_id=project_id, slug=workspace_slug, state_id=state_id, **kwargs) - return self._model_to_dict(response) + resp = self.client.states.retrieve(workspace_slug, project_id, state_id) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to retrieve state: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to retrieve state: {str(e)}") raise - def delete_state(self, workspace_slug: str, project_id: str, state_id: str) -> bool: - """Delete a state.""" + def delete_state(self, workspace_slug: str, project_id: str, state_id: str) -> Dict[str, Any]: + """Delete a state (v0.2).""" try: - self.states.delete_state(project_id=project_id, slug=workspace_slug, state_id=state_id) - return True + self.client.states.delete(workspace_slug, project_id, state_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete state: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to delete state: {str(e)}") raise @@ -722,341 +719,188 @@ class PlaneSDKAdapter: # MODULES API METHODS # ============================================================================ - def list_modules(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List modules in a project.""" + def list_modules(self, workspace_slug: str, project_id: str) -> Dict[str, Any]: + """List modules (v0.2) with normalized pagination.""" try: - response = self.modules.list_modules(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + response = self.client.modules.list(workspace_slug, project_id) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list modules: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list modules: {str(e)}") raise - def create_module(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Create a new module.""" + def create_module(self, workspace_slug: str, project_id: str, **kwargs) -> Dict[str, Any]: + """Create a new module (v0.2). Requires name in kwargs.""" try: - # Extract the module data from kwargs - module_data = {} + name = kwargs.get("name") + if not name: + raise ValueError("name is required to create a module") + payload: Dict[str, Any] = {"name": name} + for key in [ + "description", + "start_date", + "target_date", + "status", + "lead", + "members", + "external_source", + "external_id", + ]: + if key in kwargs and kwargs[key] is not None: + payload[key] = kwargs[key] - # Required field - if "name" in kwargs: - module_data["name"] = kwargs["name"] - - # Optional fields - if "description" in kwargs: - module_data["description"] = kwargs["description"] - if "start_date" in kwargs: - module_data["start_date"] = kwargs["start_date"] - if "target_date" in kwargs: - module_data["target_date"] = kwargs["target_date"] - if "status" in kwargs: - module_data["status"] = kwargs["status"] - if "lead" in kwargs: - module_data["lead"] = kwargs["lead"] - if "members" in kwargs: - module_data["members"] = kwargs["members"] - if "external_source" in kwargs: - module_data["external_source"] = kwargs["external_source"] - if "external_id" in kwargs: - module_data["external_id"] = kwargs["external_id"] - - # Create the module request object - module_create_request = ModuleCreateRequest(**module_data) - - # Call SDK method with the request object - response = self.modules.create_module(project_id=project_id, slug=workspace_slug, module_create_request=module_create_request) - return self._model_to_dict(response) + data_model = ModulesCreateModule(**payload) + resp = self.client.modules.create(workspace_slug, project_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to create module: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to create module: {str(e)}") raise - def update_module( - self, - workspace_slug: str, - project_id: str, - module_id: str, - patched_module_update_request: Optional[PatchedModuleUpdateRequest] = None, - **kwargs, - ) -> Union[Dict[str, Any], List[Any], Any]: - """Update a module.""" + def update_module(self, workspace_slug: str, project_id: str, module_id: str, **kwargs) -> Dict[str, Any]: + """Update a module (v0.2).""" try: - # Create or use provided request object - request_obj = patched_module_update_request or (PatchedModuleUpdateRequest(**kwargs) if kwargs else PatchedModuleUpdateRequest()) - - # Log exact SDK payload - try: - body_dump = request_obj.model_dump(exclude_none=True) if hasattr(request_obj, "model_dump") else dict(kwargs) - log.info(f"SDK PAYLOAD modules.update_module: pk={module_id}, project_id={project_id}, slug={workspace_slug}, body={body_dump}") - except Exception: - pass - - # Call SDK method with the request object - response = self.modules.update_module(pk=module_id, project_id=project_id, slug=workspace_slug, patched_module_update_request=request_obj) - return self._model_to_dict(response) + payload = {k: v for k, v in kwargs.items() if v is not None} + data_model = ModulesUpdateModule(**payload) + resp = self.client.modules.update(workspace_slug, project_id, module_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to update module: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to update module: {str(e)}") raise - def delete_module(self, workspace_slug: str, project_id: str, module_id: str) -> bool: - """Delete a module.""" + def delete_module(self, workspace_slug: str, project_id: str, module_id: str) -> Dict[str, Any]: + """Delete a module (v0.2).""" try: - self.modules.delete_module(pk=module_id, project_id=project_id, slug=workspace_slug) - return True + self.client.modules.delete(workspace_slug, project_id, module_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete module: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to delete module: {str(e)}") raise - def add_module_work_items( - self, workspace_slug: str, project_id: str, module_id: str, issues: List[str], **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """Add work items to a module.""" + def add_module_work_items(self, workspace_slug: str, project_id: str, module_id: str, issues: List[str]) -> Dict[str, Any]: + """Add work items to a module (v0.2).""" try: - # Create the proper request object - module_issue_request = ModuleIssueRequestRequest(issues=issues) - - # Call SDK method with the request object - response = self.modules.add_module_work_items( - module_id=module_id, project_id=project_id, slug=workspace_slug, module_issue_request_request=module_issue_request - ) - - # Convert response to dict - return self._model_to_dict(response) - + # SDK expects issue_ids as positional argument, not data= keyword + # Pass issue_ids as keyword argument to match SDK signature + self.client.modules.add_work_items(workspace_slug, project_id, module_id, issue_ids=issues) + # SDK method returns None, so return success response + return {"success": True, "issues_added": len(issues)} + except HttpError as e: + log.error(f"Failed to add module work items: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: - log.error(f"Failed to add work items to module: {str(e)}") + log.error(f"Failed to add module work items: {str(e)}") raise - def retrieve_module(self, workspace_slug: str, project_id: str, module_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Retrieve details of a specific module.""" + def retrieve_module(self, workspace_slug: str, project_id: str, module_id: str) -> Dict[str, Any]: + """Retrieve a module (v0.2).""" try: - response = self.modules.retrieve_module(pk=module_id, project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + resp = self.client.modules.retrieve(workspace_slug, project_id, module_id) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to retrieve module: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to retrieve module: {str(e)}") raise - def archive_module(self, workspace_slug: str, project_id: str, module_id: str, **kwargs) -> bool: - """Archive a module.""" + def archive_module(self, workspace_slug: str, project_id: str, module_id: str) -> Dict[str, Any]: + """Archive a module (v0.2).""" try: - self.modules.archive_module(pk=module_id, project_id=project_id, slug=workspace_slug, **kwargs) - return True + self.client.modules.archive(workspace_slug, project_id, module_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to archive module: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to archive module: {str(e)}") raise - def unarchive_module(self, workspace_slug: str, project_id: str, module_id: str, **kwargs) -> bool: - """Unarchive a module.""" + def unarchive_module(self, workspace_slug: str, project_id: str, module_id: str) -> Dict[str, Any]: + """Unarchive a module (v0.2).""" try: - self.modules.unarchive_module(pk=module_id, project_id=project_id, slug=workspace_slug, **kwargs) - return True + self.client.modules.unarchive(workspace_slug, project_id, module_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to unarchive module: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to unarchive module: {str(e)}") raise - def list_archived_modules(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List archived modules in a project.""" + def list_archived_modules(self, workspace_slug: str, project_id: str) -> Dict[str, Any]: + """List archived modules (v0.2).""" try: - response = self.modules.list_archived_modules(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + response = self.client.modules.list_archived(workspace_slug, project_id) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list archived modules: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list archived modules: {str(e)}") raise - def list_module_work_items(self, workspace_slug: str, project_id: str, module_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List work items in a module.""" + def list_module_work_items(self, workspace_slug: str, project_id: str, module_id: str) -> Dict[str, Any]: + """List work items in a module (v0.2).""" try: - response = self.modules.list_module_work_items(module_id=module_id, project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + response = self.client.modules.list_work_items(workspace_slug, project_id, module_id) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list module work items: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list module work items: {str(e)}") raise - def delete_module_work_item(self, workspace_slug: str, project_id: str, module_id: str, issue_id: str, **kwargs) -> bool: - """Remove a work item from a module.""" + def remove_module_work_item(self, workspace_slug: str, project_id: str, module_id: str, issue_id: str) -> Dict[str, Any]: + """Remove a work item from a module (v0.2 remove_work_item).""" try: - self.modules.delete_module_work_item(issue_id=issue_id, module_id=module_id, project_id=project_id, slug=workspace_slug, **kwargs) - return True + self.client.modules.remove_work_item(workspace_slug, project_id, module_id, issue_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to remove work item from module: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to remove work item from module: {str(e)}") raise - # ============================================================================ - # WORK ITEMS API METHODS - # ============================================================================ - - def list_work_items(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List work items in a project.""" - try: - response = self.work_items.list_work_items(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) - except Exception as e: - log.error(f"Failed to list work items: {str(e)}") - raise - - def get_work_item(self, workspace_slug: str, project_id: str, work_item_id: str) -> Union[Dict[str, Any], List[Any], Any]: - """Get a specific work item.""" - try: - response = self.work_items.retrieve_work_item(pk=work_item_id, project_id=project_id, slug=workspace_slug) - return self._model_to_dict(response) - except Exception as e: - log.error(f"Failed to get work item: {str(e)}") - raise - - def retrieve_work_item(self, workspace_slug: str, project_id: str, work_item_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Get a specific work item by ID.""" - try: - response = self.work_items.retrieve_work_item(pk=work_item_id, project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) - except Exception as e: - log.error(f"Failed to retrieve work item: {str(e)}") - raise - - def search_work_items(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Search work items.""" - try: - response = self.work_items.search_work_items(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) - except Exception as e: - log.error(f"Failed to search work items: {str(e)}") - raise - - def get_workspace_work_item(self, workspace_slug: str, work_item_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Get a work item across workspace.""" - try: - # This method expects issue_identifier (int) and project_identifier (str) - # For now, we'll need to parse the work_item_id if it's in format "PROJECT-123" - if "-" in str(work_item_id): - parts = str(work_item_id).split("-", 1) - project_identifier = parts[0] - issue_identifier = int(parts[1]) - else: - # Fallback - assume it's just the numeric ID and we need project info from context - issue_identifier = int(work_item_id) - project_identifier = kwargs.get("project_identifier", "UNKNOWN") - - response = self.work_items.get_workspace_work_item( - issue_identifier=issue_identifier, project_identifier=project_identifier, slug=workspace_slug, **kwargs - ) - return self._model_to_dict(response) - except Exception as e: - log.error(f"Failed to get workspace work item: {str(e)}") - raise - - def delete_work_item(self, workspace_slug: str, project_id: str, work_item_id: str) -> bool: - """Delete a work item.""" - try: - self.work_items.delete_work_item(pk=work_item_id, project_id=project_id, slug=workspace_slug) - return True - except Exception as e: - log.error(f"Failed to delete work item: {str(e)}") - raise - - # ============================================================================ - # PROJECTS API METHODS - # ============================================================================ - - def update_project( - self, - pk: Optional[str] = None, - project_id: Optional[str] = None, - slug: Optional[str] = None, - workspace_slug: Optional[str] = None, - patched_project_update_request: Optional[PatchedProjectUpdateRequest] = None, - **kwargs, - ) -> Union[Dict[str, Any], List[Any], Any]: - """Update a project (mirrors SDK signature; supports aliases).""" - try: - # Canonicalize params to SDK names - effective_pk = pk or project_id - effective_slug = slug or workspace_slug - if not effective_pk or not effective_slug: - raise ValueError("pk (project_id) and slug (workspace_slug) are required") - - # Build request object from provided model or kwargs - request_obj = patched_project_update_request - if request_obj is None and kwargs: - request_obj = PatchedProjectUpdateRequest(**kwargs) - - # Log exact SDK payload - try: - body_dump = request_obj.model_dump(exclude_none=True) if request_obj and hasattr(request_obj, "model_dump") else dict(kwargs) - log.info(f"SDK PAYLOAD projects.update_project: pk={effective_pk}, slug={effective_slug}, body={body_dump}") - except Exception: - pass - - # Call SDK method with the request object - response = self.projects.update_project( - pk=effective_pk, slug=effective_slug, patched_project_update_request=(request_obj or PatchedProjectUpdateRequest()) - ) - return self._model_to_dict(response) - except Exception as e: - log.error(f"Failed to update project: {str(e)}") - raise - - def retrieve_project( - self, - pk: Optional[str] = None, - project_id: Optional[str] = None, - slug: Optional[str] = None, - workspace_slug: Optional[str] = None, - **kwargs, - ) -> Union[Dict[str, Any], List[Any], Any]: - """Get a specific project by ID (mirrors SDK signature; supports aliases).""" - try: - effective_pk = pk or project_id - effective_slug = slug or workspace_slug - if not effective_pk or not effective_slug: - raise ValueError("pk (project_id) and slug (workspace_slug) are required") - response = self.projects.retrieve_project(pk=effective_pk, slug=effective_slug, **kwargs) - return self._model_to_dict(response) - except Exception as e: - log.error(f"Failed to retrieve project: {str(e)}") - raise - - def archive_project( - self, project_id: Optional[str] = None, pk: Optional[str] = None, slug: Optional[str] = None, workspace_slug: Optional[str] = None - ) -> bool: - """Archive a project (SDK uses project_id for this endpoint).""" - try: - effective_project_id = project_id or pk - effective_slug = slug or workspace_slug - if not effective_project_id or not effective_slug: - raise ValueError("project_id (pk) and slug (workspace_slug) are required") - self.projects.archive_project(project_id=effective_project_id, slug=effective_slug) - return True - except Exception as e: - log.error(f"Failed to archive project: {str(e)}") - raise - - def unarchive_project( - self, project_id: Optional[str] = None, pk: Optional[str] = None, slug: Optional[str] = None, workspace_slug: Optional[str] = None - ) -> bool: - """Unarchive a project (SDK uses project_id for this endpoint).""" - try: - effective_project_id = project_id or pk - effective_slug = slug or workspace_slug - if not effective_project_id or not effective_slug: - raise ValueError("project_id (pk) and slug (workspace_slug) are required") - self.projects.unarchive_project(project_id=effective_project_id, slug=effective_slug) - return True - except Exception as e: - log.error(f"Failed to unarchive project: {str(e)}") - raise - - def delete_project( - self, pk: Optional[str] = None, project_id: Optional[str] = None, slug: Optional[str] = None, workspace_slug: Optional[str] = None - ) -> bool: - """Delete a project (SDK uses pk for this endpoint).""" - try: - effective_pk = pk or project_id - effective_slug = slug or workspace_slug - if not effective_pk or not effective_slug: - raise ValueError("pk (project_id) and slug (workspace_slug) are required") - self.projects.delete_project(pk=effective_pk, slug=effective_slug) - return True - except Exception as e: - log.error(f"Failed to delete project: {str(e)}") - raise - # ============================================================================ # PAGES API METHODS # ============================================================================ @@ -1066,7 +910,7 @@ class PlaneSDKAdapter: project_id: str, slug: Optional[str] = None, workspace_slug: Optional[str] = None, - page_create_api_request: Optional[PageCreateAPIRequest] = None, + page_create_api_request: Optional[Any] = None, name: Optional[str] = None, description_html: Optional[str] = None, access: Optional[int] = None, @@ -1074,62 +918,55 @@ class PlaneSDKAdapter: logo_props: Optional[dict] = None, **kwargs, ) -> Dict[str, Any]: - """ - Create a new page in a project and return as plain dict. - - Args: - project_id: Project ID where the page will be created - slug: Workspace slug (required) - workspace_slug: Alternative name for slug parameter - page_create_api_request: Pre-built request object (optional) - name: Page name (required if page_create_api_request not provided) - description_html: Page content in HTML format (optional) - access: Access level (0=private, 1=public) (optional) - color: Page color (optional) - logo_props: Logo properties dict (optional) - **kwargs: Additional parameters - - Returns: - Dict with page data converted from SDK model - """ + """Create a project page (v0.2).""" try: effective_slug = slug or workspace_slug if not effective_slug: raise ValueError("slug (workspace_slug) is required") + if not project_id: + raise ValueError("project_id is required") - # Build request if not provided - request_obj = page_create_api_request - if request_obj is None: - if not name: - raise ValueError("name is required when page_create_api_request is not provided") - # CRITICAL: Ensure description_html is always provided (API requirement) - # API requires at least 1 character, not just non-null - if not description_html: - description_html = name + # Build payload from provided parameters + if page_create_api_request is not None: + if hasattr(page_create_api_request, "model_dump"): + payload = page_create_api_request.model_dump(exclude_none=True) + elif hasattr(page_create_api_request, "dict"): + payload = page_create_api_request.dict() + elif isinstance(page_create_api_request, dict): + payload = dict(page_create_api_request) + else: + payload = {} + else: + payload = {} - # Sanitize description_html to avoid null characters (\x00) that API rejects - try: - # Remove any NUL bytes that can creep in via encoding glitches - description_html = description_html.replace("\x00", "") - # Ensure we still have at least 1 character after sanitization - if not description_html.strip(): - description_html = name - except Exception: - description_html = name + # Override with explicit parameters if provided + if name is not None: + payload["name"] = name + if description_html is not None: + payload["description_html"] = description_html + if access is not None: + payload["access"] = access + if color is not None: + payload["color"] = color + if logo_props is not None: + payload["logo_props"] = logo_props - # ALWAYS include description_html in payload (required by API) - payload: Dict[str, Any] = {"name": name, "description_html": description_html} - if access is not None: - payload["access"] = access - if color is not None: - payload["color"] = color - if logo_props is not None: - payload["logo_props"] = logo_props - payload.update(kwargs) - request_obj = PageCreateAPIRequest(**payload) + # Add any extra kwargs + payload.update({k: v for k, v in kwargs.items() if v is not None}) - response = self.pages.create_project_page(project_id=project_id, slug=effective_slug, page_create_api_request=request_obj) - return cast(Dict[str, Any], self._model_to_dict(response)) + # Ensure name is present (required field) + if "name" not in payload or not payload["name"]: + raise ValueError("name is required to create a page") + + # Create the DTO + data_model = CreatePage(**payload) + + # Call the SDK + resp = self.client.pages.create_project_page(effective_slug, project_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to create project page: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to create project page: {str(e)}") raise @@ -1138,7 +975,7 @@ class PlaneSDKAdapter: self, slug: Optional[str] = None, workspace_slug: Optional[str] = None, - page_create_api_request: Optional[PageCreateAPIRequest] = None, + page_create_api_request: Optional[Any] = None, name: Optional[str] = None, description_html: Optional[str] = None, access: Optional[int] = None, @@ -1146,220 +983,347 @@ class PlaneSDKAdapter: logo_props: Optional[dict] = None, **kwargs, ) -> Dict[str, Any]: - """ - Create a new page in the workspace and return as plain dict. - - Args: - slug: Workspace slug (required) - workspace_slug: Alternative name for slug parameter - page_create_api_request: Pre-built request object (optional) - name: Page name (required if page_create_api_request not provided) - description_html: Page content in HTML format (optional) - access: Access level (0=private, 1=public) (optional) - color: Page color (optional) - logo_props: Logo properties dict (optional) - **kwargs: Additional parameters - - Returns: - Dict with page data converted from SDK model - """ + """Create a workspace page (v0.2).""" try: effective_slug = slug or workspace_slug if not effective_slug: raise ValueError("slug (workspace_slug) is required") - request_obj = page_create_api_request - if request_obj is None: - if not name: - raise ValueError("name is required when page_create_api_request is not provided") - # CRITICAL: Ensure description_html is always provided (API requirement) - # API requires at least 1 character, not just non-null - if not description_html: - description_html = name + # Build payload from provided parameters + if page_create_api_request is not None: + if hasattr(page_create_api_request, "model_dump"): + payload = page_create_api_request.model_dump(exclude_none=True) + elif hasattr(page_create_api_request, "dict"): + payload = page_create_api_request.dict() + elif isinstance(page_create_api_request, dict): + payload = dict(page_create_api_request) + else: + payload = {} + else: + payload = {} - # Sanitize description_html to avoid null characters (\x00) that API rejects - try: - # Remove any NUL bytes that can creep in via encoding glitches - description_html = description_html.replace("\x00", "") - # Ensure we still have at least 1 character after sanitization - if not description_html.strip(): - description_html = name - except Exception: - description_html = name + # Override with explicit parameters if provided + if name is not None: + payload["name"] = name + if description_html is not None: + payload["description_html"] = description_html + if access is not None: + payload["access"] = access + if color is not None: + payload["color"] = color + if logo_props is not None: + payload["logo_props"] = logo_props - # ALWAYS include description_html in payload (required by API) - payload: Dict[str, Any] = {"name": name, "description_html": description_html} - if access is not None: - payload["access"] = access - if color is not None: - payload["color"] = color - if logo_props is not None: - payload["logo_props"] = logo_props - payload.update(kwargs) - request_obj = PageCreateAPIRequest(**payload) + # Add any extra kwargs + payload.update({k: v for k, v in kwargs.items() if v is not None}) - response = self.pages.create_workspace_page(slug=effective_slug, page_create_api_request=request_obj) - return cast(Dict[str, Any], self._model_to_dict(response)) + # Ensure name is present (required field) + if "name" not in payload or not payload["name"]: + raise ValueError("name is required to create a page") + + # Create the DTO + data_model = CreatePage(**payload) + + # Call the SDK + resp = self.client.pages.create_workspace_page(effective_slug, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to create workspace page: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to create workspace page: {str(e)}") raise + def list_workspace_pages(self, workspace_slug: str) -> Dict[str, Any]: + """List workspace pages (v0.2).""" + try: + response = self.client.pages.list_workspace_pages(workspace_slug) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list workspace pages: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to list workspace pages: {str(e)}") + raise + + def list_project_pages(self, workspace_slug: str, project_id: str) -> Dict[str, Any]: + """List project pages (v0.2).""" + try: + response = self.client.pages.list_project_pages(workspace_slug, project_id) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list project pages: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to list project pages: {str(e)}") + raise + + def retrieve_workspace_page(self, workspace_slug: str, page_id: str) -> Dict[str, Any]: + """Retrieve a workspace page (v0.2).""" + try: + resp = self.client.pages.retrieve_workspace_page(workspace_slug, page_id) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to retrieve workspace page: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to retrieve workspace page: {str(e)}") + raise + + def retrieve_project_page(self, workspace_slug: str, project_id: str, page_id: str) -> Dict[str, Any]: + """Retrieve a project page (v0.2).""" + try: + resp = self.client.pages.retrieve_project_page(workspace_slug, project_id, page_id) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to retrieve project page: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to retrieve project page: {str(e)}") + raise + # ============================================================================ # CYCLES API METHODS # ============================================================================ - def update_cycle( + def create_cycle( self, workspace_slug: str, project_id: str, - pk: str, - patched_cycle_update_request: Optional[PatchedCycleUpdateRequest] = None, + name: str, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + description: Optional[str] = None, + owned_by: Optional[str] = None, **kwargs, - ) -> Union[Dict[str, Any], List[Any], Any]: - """Update a cycle.""" + ) -> Dict[str, Any]: + """Create a cycle using PlaneClient v0.2 and return as dict.""" try: - # Create or use provided request object - request_obj = patched_cycle_update_request - normalized_kwargs = dict(kwargs) - # Normalize date strings to datetime if provided in kwargs - try: - if "start_date" in normalized_kwargs and isinstance(normalized_kwargs["start_date"], str): - normalized_kwargs["start_date"] = datetime.fromisoformat(normalized_kwargs["start_date"].replace("Z", "+00:00")) - except Exception: - pass - try: - if "end_date" in normalized_kwargs and isinstance(normalized_kwargs["end_date"], str): - normalized_kwargs["end_date"] = datetime.fromisoformat(normalized_kwargs["end_date"].replace("Z", "+00:00")) - except Exception: - pass - if request_obj is None and normalized_kwargs: - request_obj = PatchedCycleUpdateRequest(**normalized_kwargs) + # API requires both dates or neither - validate early + if (start_date is None) != (end_date is None): + raise ValueError("Both start_date and end_date are required together, or neither should be provided") - # Log exact SDK payload - try: - body_dump = ( - request_obj.model_dump(exclude_none=True) if request_obj and hasattr(request_obj, "model_dump") else dict(normalized_kwargs) - ) - log.info(f"SDK PAYLOAD cycles.update_cycle: pk={pk}, project_id={project_id}, slug={workspace_slug}, body={body_dump}") - except Exception: - pass + data = {"name": name, "project_id": project_id} + if start_date: + data["start_date"] = start_date + if end_date: + data["end_date"] = end_date + if description: + data["description"] = description + if not owned_by: + owned_by = self._get_current_user_id() + if owned_by: + data["owned_by"] = owned_by + else: + # CreateCycle requires owned_by; fail early with clear message + raise ValueError("owner could not be determined; please provide owned_by explicitly") - # Call SDK method with the request object - response = self.cycles.update_cycle( - pk=pk, project_id=project_id, slug=workspace_slug, patched_cycle_update_request=(request_obj or PatchedCycleUpdateRequest()) - ) - return self._model_to_dict(response) + data["project_id"] = project_id + + # Only include kwargs that are not None to avoid sending null values to API + if kwargs: + filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None} + data.update(filtered_kwargs) + print("create cycle p args", workspace_slug, project_id) + print("create cycle data", data) + + model = CreateCycle(**data) + cycle = self.client.cycles.create(workspace_slug, project_id, data=model) + return cast(Dict[str, Any], self._model_to_dict(cycle)) + except HttpError as e: + log.error(f"Failed to create cycle: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: - log.error(f"Failed to update cycle: {str(e)}") + log.error(f"Failed to create cycle: {str(e)}") raise - def retrieve_cycle(self, workspace_slug: str, project_id: str, pk: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Get a specific cycle by ID.""" + def list_cycles(self, workspace_slug: str, project_id: str, per_page: Optional[int] = None, page: Optional[int] = None) -> Dict[str, Any]: + """List cycles with normalized pagination.""" try: - response = self.cycles.retrieve_cycle(pk=pk, project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + response = self.client.cycles.list(workspace_slug, project_id) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list cycles: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to list cycles: {str(e)}") + raise + + def retrieve_cycle(self, workspace_slug: str, project_id: str, cycle_id: str) -> Dict[str, Any]: + """Get a specific cycle by ID using v0.2 client.""" + try: + response = self.client.cycles.retrieve(workspace_slug, project_id, cycle_id) + return cast(Dict[str, Any], self._model_to_dict(response)) + except HttpError as e: + log.error(f"Failed to retrieve cycle: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to retrieve cycle: {str(e)}") raise - def archive_cycle(self, workspace_slug: str, project_id: str, pk: str) -> bool: - """Archive a cycle.""" + def update_cycle( + self, + workspace_slug: str, + project_id: str, + cycle_id: str, + **kwargs, + ) -> Dict[str, Any]: + """Update a cycle using PlaneClient v0.2 and return as dict.""" try: - self.cycles.archive_cycle(cycle_id=pk, project_id=project_id, slug=workspace_slug) - return True + # Build payload with only non-None values + payload = {k: v for k, v in kwargs.items() if v is not None} + + model = UpdateCycle(**payload) + cycle = self.client.cycles.update(workspace_slug, project_id, cycle_id, data=model) + return cast(Dict[str, Any], self._model_to_dict(cycle)) + except HttpError as e: + log.error(f"Failed to update cycle: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to update cycle: {str(e)}") + raise + + def archive_cycle(self, workspace_slug: str, project_id: str, cycle_id: str) -> Dict[str, Any]: + """Archive a cycle (v0.2).""" + try: + self.client.cycles.archive(workspace_slug, project_id, cycle_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to archive cycle: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to archive cycle: {str(e)}") raise - def unarchive_cycle(self, workspace_slug: str, project_id: str, pk: str) -> bool: - """Unarchive a cycle.""" + def unarchive_cycle(self, workspace_slug: str, project_id: str, cycle_id: str) -> Dict[str, Any]: + """Unarchive a cycle (v0.2).""" try: - self.cycles.unarchive_cycle(pk=pk, project_id=project_id, slug=workspace_slug) - return True + self.client.cycles.unarchive(workspace_slug, project_id, cycle_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to unarchive cycle: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to unarchive cycle: {str(e)}") raise - def list_archived_cycles(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List archived cycles.""" + def list_archived_cycles(self, workspace_slug: str, project_id: str) -> Dict[str, Any]: + """List archived cycles (v0.2).""" try: - response = self.cycles.list_archived_cycles(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + response = self.client.cycles.list_archived(workspace_slug, project_id) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list archived cycles: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list archived cycles: {str(e)}") raise - def add_cycle_work_items( - self, workspace_slug: str, project_id: str, cycle_id: str, issues: list, **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """Add work items to a cycle.""" + def add_cycle_work_items(self, workspace_slug: str, project_id: str, cycle_id: str, issues: List[str]) -> Dict[str, Any]: + """Add work items to a cycle (v0.2).""" + # log all input arguments + log.info(f"Adding work items to cycle {workspace_slug}, {project_id}, {cycle_id}, {issues}") try: - # Create a single request with all issues (batch approach) - cycle_issue_request = CycleIssueRequestRequest(issues=issues) - - # Call the SDK method once with all issues - response = self.cycles.add_cycle_work_items( - cycle_id=cycle_id, project_id=project_id, slug=workspace_slug, cycle_issue_request_request=cycle_issue_request - ) - - # Convert response to dict - return self._model_to_dict(response) - + # Pass request as positional argument (not data= keyword) + response = self.client.cycles.add_work_items(workspace_slug, project_id, cycle_id, issue_ids=issues) + return cast(Dict[str, Any], self._model_to_dict(response)) + except HttpError as e: + log.error(f"Failed to add work items to cycle: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to add work items to cycle: {str(e)}") raise - def list_cycle_work_items(self, workspace_slug: str, project_id: str, cycle_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List work items in a cycle.""" + def list_cycle_work_items(self, workspace_slug: str, project_id: str, cycle_id: str) -> Dict[str, Any]: + """List work items in a cycle (v0.2).""" try: - response = self.cycles.list_cycle_work_items(cycle_id=cycle_id, project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + response = self.client.cycles.list_work_items(workspace_slug, project_id, cycle_id) + items = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(items, list): + items = [items] if items else [] + return { + "results": items, + "count": len(items), + "total_results": getattr(response, "total_count", len(items)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list cycle work items: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list cycle work items: {str(e)}") raise - def retrieve_cycle_work_item( - self, workspace_slug: str, project_id: str, cycle_id: str, issue_id: str, **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """Get a specific work item in a cycle.""" + def remove_cycle_work_item(self, workspace_slug: str, project_id: str, cycle_id: str, work_item_id: str) -> Dict[str, Any]: + """Remove a work item from a cycle (v0.2 remove_work_item).""" try: - response = self.cycles.retrieve_cycle_work_item( - cycle_id=cycle_id, project_id=project_id, slug=workspace_slug, issue_id=issue_id, **kwargs - ) - return self._model_to_dict(response) + self.client.cycles.remove_work_item(workspace_slug, project_id, cycle_id, work_item_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to remove work item from cycle: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: - log.error(f"Failed to retrieve cycle work item: {str(e)}") + log.error(f"Failed to remove work item from cycle: {str(e)}") raise - def delete_cycle_work_item(self, workspace_slug: str, project_id: str, cycle_id: str, issue_id: str) -> bool: - """Remove a work item from a cycle.""" + def transfer_cycle_work_items(self, workspace_slug: str, project_id: str, cycle_id: str, new_cycle_id: str) -> Dict[str, Any]: + """Transfer work items between cycles (v0.2).""" try: - self.cycles.delete_cycle_work_item(cycle_id=cycle_id, project_id=project_id, slug=workspace_slug, issue_id=issue_id) - return True - except Exception as e: - log.error(f"Failed to delete cycle work item: {str(e)}") + payload = {"new_cycle_id": new_cycle_id} + resp = self.client.cycles.transfer_work_items(workspace_slug, project_id, cycle_id, data=payload) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to transfer cycle work items: {e} ({getattr(e, "status_code", None)})") raise - - def transfer_cycle_work_items( - self, workspace_slug: str, project_id: str, cycle_id: str, new_cycle_id: str, **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """Transfer work items between cycles.""" - try: - # Create the proper request object - transfer_request = TransferCycleIssueRequestRequest(new_cycle_id=new_cycle_id) - response = self.cycles.transfer_cycle_work_items( - cycle_id=cycle_id, project_id=project_id, slug=workspace_slug, transfer_cycle_issue_request_request=transfer_request - ) - return self._model_to_dict(response) except Exception as e: log.error(f"Failed to transfer cycle work items: {str(e)}") raise - def delete_cycle(self, workspace_slug: str, project_id: str, pk: str) -> bool: - """Delete a cycle.""" + def delete_cycle(self, workspace_slug: str, project_id: str, cycle_id: str) -> Dict[str, Any]: + """Delete a cycle (v0.2).""" try: - self.cycles.delete_cycle(pk=pk, project_id=project_id, slug=workspace_slug) - return True + self.client.cycles.delete(workspace_slug, project_id, cycle_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete cycle: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to delete cycle: {str(e)}") raise @@ -1368,55 +1332,75 @@ class PlaneSDKAdapter: # INTAKE API METHODS # ============================================================================ - def create_intake_work_item(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Create a new intake work item.""" + def create_intake_work_item(self, workspace_slug: str, project_id: str, **kwargs) -> Dict[str, Any]: + """Create a new intake work item (v0.2).""" try: - # Create the intake issue request object - intake_request = IntakeIssueCreateRequest(**kwargs) - - response = self.intake.create_intake_work_item(project_id=project_id, slug=workspace_slug, intake_issue_create_request=intake_request) - return self._model_to_dict(response) + payload = {k: v for k, v in kwargs.items() if v is not None} + data_model = CreateIntakeWorkItem(**payload) + resp = self.client.intake.create(workspace_slug, project_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to create intake work item: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to create intake work item: {str(e)}") raise - def get_intake_work_items_list(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List intake work items.""" + def get_intake_work_items_list(self, workspace_slug: str, project_id: str) -> Dict[str, Any]: + """List intake work items (v0.2).""" try: - response = self.intake.get_intake_work_items_list(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + response = self.client.intake.list(workspace_slug, project_id) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list intake work items: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list intake work items: {str(e)}") raise - def retrieve_intake_work_item(self, workspace_slug: str, project_id: str, intake_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Retrieve a specific intake work item.""" + def retrieve_intake_work_item(self, workspace_slug: str, project_id: str, intake_id: str) -> Dict[str, Any]: + """Retrieve a specific intake work item (v0.2).""" try: - response = self.intake.retrieve_intake_work_item(issue_id=intake_id, project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + resp = self.client.intake.retrieve(workspace_slug, project_id, intake_id) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to retrieve intake work item: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to retrieve intake work item: {str(e)}") raise - def update_intake_work_item(self, workspace_slug: str, project_id: str, intake_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Update an intake work item.""" + def update_intake_work_item(self, workspace_slug: str, project_id: str, intake_id: str, **kwargs) -> Dict[str, Any]: + """Update an intake work item (v0.2).""" try: - # Create the patched intake issue request object - patched_intake_request = PatchedIntakeIssueUpdateRequest(**kwargs) - - response = self.intake.update_intake_work_item( - issue_id=intake_id, project_id=project_id, slug=workspace_slug, patched_intake_issue_update_request=patched_intake_request - ) - return self._model_to_dict(response) + payload = {k: v for k, v in kwargs.items() if v is not None} + data_model = UpdateIntakeWorkItem(**payload) + resp = self.client.intake.update(workspace_slug, project_id, intake_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to update intake work item: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to update intake work item: {str(e)}") raise - def delete_intake_work_item(self, workspace_slug: str, project_id: str, intake_id: str) -> bool: - """Delete an intake work item.""" + def delete_intake_work_item(self, workspace_slug: str, project_id: str, intake_id: str) -> Dict[str, Any]: + """Delete an intake work item (v0.2).""" try: - self.intake.delete_intake_work_item(issue_id=intake_id, project_id=project_id, slug=workspace_slug) - return True + self.client.intake.delete(workspace_slug, project_id, intake_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete intake work item: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to delete intake work item: {str(e)}") raise @@ -1425,20 +1409,50 @@ class PlaneSDKAdapter: # MEMBERS API METHODS # ============================================================================ - def get_workspace_members(self, workspace_slug: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Get all workspace members.""" + def get_workspace_members(self, workspace_slug: str) -> Dict[str, Any]: + """Get list of all workspace members and their counts. + Returns user_id, first_name, last_name, email, and display_name of each member. + Includes bot users.""" try: - response = self.members.get_workspace_members(slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + # SDK returns a list of UserLite objects directly, not a paginated response + response = self.client.workspaces.get_members(workspace_slug) + results = self._model_to_dict(response) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": len(results), + "next_cursor": None, + "prev_cursor": None, + } + except HttpError as e: + log.error(f"Failed to get workspace members: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to get workspace members: {str(e)}") raise - def get_project_members(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Get all project members.""" + def get_project_members(self, workspace_slug: str, project_id: str) -> Dict[str, Any]: + """Get list of all project members and their counts. + Returns user_id, first_name, last_name, email, and display_name of each member. + Includes bot users.""" try: - response = self.members.get_project_members(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + # SDK returns a list of UserLite objects directly, not a paginated response + response = self.client.projects.get_members(workspace_slug, project_id) + results = self._model_to_dict(response) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": len(results), + "next_cursor": None, + "prev_cursor": None, + } + except HttpError as e: + log.error(f"Failed to get project members: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to get project members: {str(e)}") raise @@ -1447,20 +1461,39 @@ class PlaneSDKAdapter: # ACTIVITY API METHODS # ============================================================================ - def list_work_item_activities(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List work item activities.""" + def list_work_item_activities(self, workspace_slug: str, project_id: str, issue_id: Optional[str] = None) -> Dict[str, Any]: + """List work item activities (v0.2).""" try: - response = self.work_item_activity.list_work_item_activities(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + if issue_id: + response = self.client.work_items.activity.list(workspace_slug, project_id, issue_id) + else: + # Best-effort: try project-level activities + response = self.client.work_items.activity.list(workspace_slug, project_id, "") + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list work item activities: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list work item activities: {str(e)}") raise - def retrieve_work_item_activity(self, workspace_slug: str, project_id: str, activity_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Retrieve a specific work item activity.""" + def retrieve_work_item_activity(self, workspace_slug: str, project_id: str, issue_id: str, activity_id: str) -> Dict[str, Any]: + """Retrieve a specific work item activity (v0.2).""" try: - response = self.work_item_activity.retrieve_work_item_activity(pk=activity_id, project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + resp = self.client.work_items.activity.retrieve(workspace_slug, project_id, issue_id, activity_id) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to retrieve work item activity: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to retrieve work item activity: {str(e)}") raise @@ -1469,47 +1502,79 @@ class PlaneSDKAdapter: # ATTACHMENTS API METHODS # ============================================================================ - def create_work_item_attachment(self, workspace_slug: str, project_id: str, issue_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Create a work item attachment.""" + def create_work_item_attachment(self, workspace_slug: str, project_id: str, issue_id: str, **kwargs) -> Dict[str, Any]: + """Create a work item attachment (v0.2).""" try: - # Create the attachment upload request object - attachment_request = IssueAttachmentUploadRequest(**kwargs) - - response = self.work_item_attachments.create_work_item_attachment( - issue_id=issue_id, project_id=project_id, slug=workspace_slug, issue_attachment_upload_request=attachment_request - ) - return self._model_to_dict(response) + payload = {k: v for k, v in kwargs.items() if v is not None} + data_model = WorkItemAttachmentUploadRequest(**payload) + resp = self.client.work_items.attachments.create(workspace_slug, project_id, issue_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to create work item attachment: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to create work item attachment: {str(e)}") raise - def list_work_item_attachments(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List work item attachments.""" + def list_work_item_attachments(self, workspace_slug: str, project_id: str, issue_id: Optional[str] = None) -> Dict[str, Any]: + """List work item attachments (v0.2).""" try: - response = self.work_item_attachments.list_work_item_attachments(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + if issue_id: + response = self.client.work_items.attachments.list(workspace_slug, project_id, issue_id) + else: + # Best-effort: try project-level attachments list (may not be available) + response = self.client.work_items.attachments.list(workspace_slug, project_id, "") + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list work item attachments: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list work item attachments: {str(e)}") raise - def retrieve_work_item_attachment( - self, workspace_slug: str, project_id: str, attachment_id: str, **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """Retrieve a specific work item attachment.""" + def retrieve_work_item_attachment(self, workspace_slug: str, project_id: str, issue_id: str, attachment_id: str) -> Dict[str, Any]: + """Retrieve a specific work item attachment (v0.2).""" try: - response = self.work_item_attachments.retrieve_work_item_attachment( - pk=attachment_id, project_id=project_id, slug=workspace_slug, **kwargs - ) - return self._model_to_dict(response) + resp = self.client.work_items.attachments.retrieve(workspace_slug, project_id, issue_id, attachment_id) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to retrieve work item attachment: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to retrieve work item attachment: {str(e)}") raise - def delete_work_item_attachment(self, workspace_slug: str, project_id: str, issue_id: str, attachment_id: str) -> bool: - """Delete a work item attachment.""" + def update_work_item_attachment(self, workspace_slug: str, project_id: str, issue_id: str, attachment_id: str, **kwargs) -> Dict[str, Any]: + """Update a work item attachment (v0.2).""" try: - self.work_item_attachments.delete_work_item_attachment(issue_id=issue_id, pk=attachment_id, project_id=project_id, slug=workspace_slug) - return True + payload = {k: v for k, v in kwargs.items() if v is not None} + data_model = UpdateWorkItemAttachment(**payload) + resp = self.client.work_items.attachments.update(workspace_slug, project_id, issue_id, attachment_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to update work item attachment: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to update work item attachment: {str(e)}") + raise + + def delete_work_item_attachment(self, workspace_slug: str, project_id: str, issue_id: str, attachment_id: str) -> Dict[str, Any]: + """Delete a work item attachment (v0.2).""" + try: + self.client.work_items.attachments.delete(workspace_slug, project_id, issue_id, attachment_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete work item attachment: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to delete work item attachment: {str(e)}") raise @@ -1518,63 +1583,79 @@ class PlaneSDKAdapter: # COMMENTS API METHODS # ============================================================================ - def create_work_item_comment(self, workspace_slug: str, project_id: str, issue_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Create a work item comment.""" + def create_work_item_comment(self, workspace_slug: str, project_id: str, issue_id: str, **kwargs) -> Dict[str, Any]: + """Create a work item comment (v0.2). Requires comment or comment_html.""" try: - # Create the comment request object - comment_request = IssueCommentCreateRequest(**kwargs) - - response = self.work_item_comments.create_work_item_comment( - issue_id=issue_id, project_id=project_id, slug=workspace_slug, issue_comment_create_request=comment_request - ) - return self._model_to_dict(response) + payload = {k: v for k, v in kwargs.items() if v is not None} + data_obj = CreateWorkItemComment(**payload) + resp = self.client.work_items.comments.create(workspace_slug, project_id, issue_id, data=data_obj) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to create work item comment: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to create work item comment: {str(e)}") raise - def list_work_item_comments(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List work item comments.""" + def list_work_item_comments(self, workspace_slug: str, project_id: str, issue_id: Optional[str] = None) -> Dict[str, Any]: + """List work item comments (v0.2). If issue_id is provided, list for that issue only.""" try: - response = self.work_item_comments.list_work_item_comments(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + if issue_id: + response = self.client.work_items.comments.list(workspace_slug, project_id, issue_id) + else: + # Best-effort: try project-level comments list (may not be available) + response = self.client.work_items.comments.list(workspace_slug, project_id, "") + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list work item comments: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list work item comments: {str(e)}") raise - def retrieve_work_item_comment(self, workspace_slug: str, project_id: str, comment_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Retrieve a specific work item comment.""" + def retrieve_work_item_comment(self, workspace_slug: str, project_id: str, issue_id: str, comment_id: str) -> Dict[str, Any]: + """Retrieve a specific work item comment (v0.2).""" try: - response = self.work_item_comments.retrieve_work_item_comment(pk=comment_id, project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + resp = self.client.work_items.comments.retrieve(workspace_slug, project_id, issue_id, comment_id) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to retrieve work item comment: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to retrieve work item comment: {str(e)}") raise - def update_work_item_comment( - self, workspace_slug: str, project_id: str, issue_id: str, comment_id: str, **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """Update a work item comment.""" + def update_work_item_comment(self, workspace_slug: str, project_id: str, issue_id: str, comment_id: str, **kwargs) -> Dict[str, Any]: + """Update a work item comment (v0.2).""" try: - # Create the patched comment request object - patched_comment_request = PatchedIssueCommentCreateRequest(**kwargs) - - response = self.work_item_comments.update_work_item_comment( - issue_id=issue_id, - pk=comment_id, - project_id=project_id, - slug=workspace_slug, - patched_issue_comment_create_request=patched_comment_request, - ) - return self._model_to_dict(response) + payload = {k: v for k, v in kwargs.items() if v is not None} + data_obj = UpdateWorkItemComment(**payload) + resp = self.client.work_items.comments.update(workspace_slug, project_id, issue_id, comment_id, data=data_obj) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to update work item comment: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to update work item comment: {str(e)}") raise - def delete_work_item_comment(self, workspace_slug: str, project_id: str, issue_id: str, comment_id: str) -> bool: - """Delete a work item comment.""" + def delete_work_item_comment(self, workspace_slug: str, project_id: str, issue_id: str, comment_id: str) -> Dict[str, Any]: + """Delete a work item comment (v0.2).""" try: - self.work_item_comments.delete_work_item_comment(issue_id=issue_id, pk=comment_id, project_id=project_id, slug=workspace_slug) - return True + self.client.work_items.comments.delete(workspace_slug, project_id, issue_id, comment_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete work item comment: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to delete work item comment: {str(e)}") raise @@ -1583,57 +1664,79 @@ class PlaneSDKAdapter: # LINKS API METHODS # ============================================================================ - def create_work_item_link(self, workspace_slug: str, project_id: str, issue_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Create a work item link.""" + def create_work_item_link(self, workspace_slug: str, project_id: str, issue_id: str, **kwargs) -> Dict[str, Any]: + """Create a work item link (v0.2). Requires url.""" try: - # Create the link request object - link_request = IssueLinkCreateRequest(**kwargs) - - response = self.work_item_links.create_work_item_link( - issue_id=issue_id, project_id=project_id, slug=workspace_slug, issue_link_create_request=link_request - ) - return self._model_to_dict(response) + payload = {k: v for k, v in kwargs.items() if v is not None} + data_model = CreateWorkItemLink(**payload) + resp = self.client.work_items.links.create(workspace_slug, project_id, issue_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to create work item link: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to create work item link: {str(e)}") raise - def list_work_item_links(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List work item links.""" + def list_work_item_links(self, workspace_slug: str, project_id: str, issue_id: Optional[str] = None) -> Dict[str, Any]: + """List work item links (v0.2).""" try: - response = self.work_item_links.list_work_item_links(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + if issue_id: + response = self.client.work_items.links.list(workspace_slug, project_id, issue_id) + else: + # Best-effort: try project-level links list (may not be available) + response = self.client.work_items.links.list(workspace_slug, project_id, "") + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list work item links: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list work item links: {str(e)}") raise - def retrieve_work_item_link(self, workspace_slug: str, project_id: str, link_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Retrieve a specific work item link.""" + def retrieve_work_item_link(self, workspace_slug: str, project_id: str, issue_id: str, link_id: str) -> Dict[str, Any]: + """Retrieve a specific work item link (v0.2).""" try: - response = self.work_item_links.retrieve_work_item_link(pk=link_id, project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + resp = self.client.work_items.links.retrieve(workspace_slug, project_id, issue_id, link_id) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to retrieve work item link: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to retrieve work item link: {str(e)}") raise - def update_issue_link(self, workspace_slug: str, project_id: str, issue_id: str, link_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Update a work item link.""" + def update_issue_link(self, workspace_slug: str, project_id: str, issue_id: str, link_id: str, **kwargs) -> Dict[str, Any]: + """Update a work item link (v0.2).""" try: - # Create the patched link request object - patched_link_request = PatchedIssueLinkUpdateRequest(**kwargs) - - response = self.work_item_links.update_issue_link( - issue_id=issue_id, pk=link_id, project_id=project_id, slug=workspace_slug, patched_issue_link_update_request=patched_link_request - ) - return self._model_to_dict(response) + payload = {k: v for k, v in kwargs.items() if v is not None} + data_model = UpdateWorkItemLink(**payload) + resp = self.client.work_items.links.update(workspace_slug, project_id, issue_id, link_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to update work item link: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to update work item link: {str(e)}") raise - def delete_work_item_link(self, workspace_slug: str, project_id: str, issue_id: str, link_id: str) -> bool: - """Delete a work item link.""" + def delete_work_item_link(self, workspace_slug: str, project_id: str, issue_id: str, link_id: str) -> Dict[str, Any]: + """Delete a work item link (v0.2).""" try: - self.work_item_links.delete_work_item_link(issue_id=issue_id, pk=link_id, project_id=project_id, slug=workspace_slug) - return True + self.client.work_items.links.delete(workspace_slug, project_id, issue_id, link_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete work item link: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to delete work item link: {str(e)}") raise @@ -1642,228 +1745,185 @@ class PlaneSDKAdapter: # PROPERTIES API METHODS # ============================================================================ - def create_issue_property(self, workspace_slug: str, project_id: str, type_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Create an issue property.""" + def create_issue_property(self, workspace_slug: str, project_id: str, type_id: str, **kwargs) -> Dict[str, Any]: + """Create an issue property (v0.2).""" try: - # Create the property request object - property_request = IssuePropertyAPIRequest(**kwargs) - - response = self.work_item_properties.create_issue_property( - project_id=project_id, slug=workspace_slug, type_id=type_id, issue_property_api_request=property_request - ) - return self._model_to_dict(response) + # Use SDK model for validation and serialization + property_data = CreateWorkItemProperty(**kwargs) + resp = self.client.work_item_properties.create(workspace_slug, project_id, type_id, data=property_data) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to create issue property: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to create issue property: {str(e)}") raise - def list_issue_properties(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List issue properties.""" + def list_issue_properties(self, workspace_slug: str, project_id: str, type_id: str) -> Dict[str, Any]: + """List issue properties (v0.2).""" try: - response = self.work_item_properties.list_issue_properties(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + response = self.client.work_item_properties.list(workspace_slug, project_id, type_id) + # SDK returns list directly, not a paginated response + results = self._model_to_dict(response) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": len(results), + "next_cursor": None, + "prev_cursor": None, + } + except HttpError as e: + log.error(f"Failed to list issue properties: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list issue properties: {str(e)}") raise - def retrieve_issue_property( - self, workspace_slug: str, project_id: str, property_id: str, type_id: str, **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """Retrieve a specific issue property.""" + def retrieve_issue_property(self, workspace_slug: str, project_id: str, type_id: str, property_id: str) -> Dict[str, Any]: + """Retrieve a specific issue property (v0.2).""" try: - response = self.work_item_properties.retrieve_issue_property( - project_id=project_id, property_id=property_id, slug=workspace_slug, type_id=type_id, **kwargs - ) - return self._model_to_dict(response) + resp = self.client.work_item_properties.retrieve(workspace_slug, project_id, type_id, property_id) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to retrieve issue property: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to retrieve issue property: {str(e)}") raise - def update_issue_property( - self, workspace_slug: str, project_id: str, property_id: str, type_id: str, **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """Update an issue property.""" + def update_issue_property(self, workspace_slug: str, project_id: str, type_id: str, property_id: str, **kwargs) -> Dict[str, Any]: + """Update an issue property (v0.2).""" try: - # Create the patched property request object - patched_property_request = PatchedIssuePropertyAPIRequest(**kwargs) - - response = self.work_item_properties.update_issue_property( - project_id=project_id, - property_id=property_id, - slug=workspace_slug, - type_id=type_id, - patched_issue_property_api_request=patched_property_request, - ) - return self._model_to_dict(response) + # Use SDK model for validation and serialization + property_data = UpdateWorkItemProperty(**kwargs) + resp = self.client.work_item_properties.update(workspace_slug, project_id, type_id, property_id, data=property_data) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to update issue property: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to update issue property: {str(e)}") raise - def delete_issue_property(self, workspace_slug: str, project_id: str, property_id: str, type_id: str) -> bool: - """Delete an issue property.""" + def delete_issue_property(self, workspace_slug: str, project_id: str, type_id: str, property_id: str) -> Dict[str, Any]: + """Delete an issue property (v0.2).""" try: - self.work_item_properties.delete_issue_property(project_id=project_id, property_id=property_id, slug=workspace_slug, type_id=type_id) - return True + self.client.work_item_properties.delete(workspace_slug, project_id, type_id, property_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete issue property: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to delete issue property: {str(e)}") raise def create_issue_property_option( - self, workspace_slug: str, project_id: str, property_id: str, type_id: str, **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """Create an issue property option.""" - try: - # Create the property option request object - option_request = IssuePropertyOptionAPIRequest(**kwargs) - - response = self.work_item_properties.create_issue_property_option( - project_id=project_id, property_id=property_id, slug=workspace_slug, issue_property_option_api_request=option_request - ) - return self._model_to_dict(response) - except Exception as e: - log.error(f"Failed to create issue property option: {str(e)}") - raise + self, workspace_slug: str, project_id: str, property_id: str, type_id: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: + """Create an issue property option (v0.2 - not yet implemented).""" + raise NotImplementedError("create_issue_property_option not yet available in v0.2") def create_issue_property_value( - self, workspace_slug: str, project_id: str, property_id: str, type_id: str, issue_id: str, **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """Create an issue property value.""" - try: - # Create the property value request object - value_request = IssuePropertyValueAPIRequest(**kwargs) + self, workspace_slug: str, project_id: str, property_id: str, type_id: Optional[str] = None, issue_id: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: + """Create an issue property value (v0.2 - not yet implemented).""" + raise NotImplementedError("create_issue_property_value not yet available in v0.2") - response = self.work_item_properties.create_issue_property_value( - project_id=project_id, - property_id=property_id, - slug=workspace_slug, - issue_id=issue_id, - issue_property_value_api_request=value_request, - ) - return self._model_to_dict(response) - except Exception as e: - log.error(f"Failed to create issue property value: {str(e)}") - raise + def list_issue_property_options(self, workspace_slug: str, project_id: str, property_id: str, type_id: Optional[str] = None) -> Dict[str, Any]: + """List issue property options (v0.2 - not yet implemented).""" + raise NotImplementedError("list_issue_property_options not yet available in v0.2") - def list_issue_property_options( - self, workspace_slug: str, project_id: str, property_id: str, type_id: str, **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """List issue property options.""" - try: - response = self.work_item_properties.list_issue_property_options( - project_id=project_id, property_id=property_id, slug=workspace_slug, **kwargs - ) - return self._model_to_dict(response) - except Exception as e: - log.error(f"Failed to list issue property options: {str(e)}") - raise + def list_issue_property_values(self, workspace_slug: str, project_id: str, type_id: str, issue_id: str) -> Dict[str, Any]: + """List issue property values (v0.2 - not yet implemented).""" + raise NotImplementedError("list_issue_property_values not yet available in v0.2") - def list_issue_property_values( - self, workspace_slug: str, project_id: str, type_id: str, issue_id: str, **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """List issue property values.""" - try: - response = self.work_item_properties.list_issue_property_values(project_id=project_id, slug=workspace_slug, issue_id=issue_id, **kwargs) - return self._model_to_dict(response) - except Exception as e: - log.error(f"Failed to list issue property values: {str(e)}") - raise - - def retrieve_issue_property_option( - self, workspace_slug: str, project_id: str, property_id: str, type_id: str, option_id: str, **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """Retrieve an issue property option.""" - try: - response = self.work_item_properties.retrieve_issue_property_option( - project_id=project_id, property_id=property_id, slug=workspace_slug, option_id=option_id, **kwargs - ) - return self._model_to_dict(response) - except Exception as e: - log.error(f"Failed to retrieve issue property option: {str(e)}") - raise + def retrieve_issue_property_option(self, workspace_slug: str, project_id: str, property_id: str, type_id: str, option_id: str) -> Dict[str, Any]: + """Retrieve an issue property option (v0.2 - not yet implemented).""" + raise NotImplementedError("retrieve_issue_property_option not yet available in v0.2") def update_issue_property_option( self, workspace_slug: str, project_id: str, property_id: str, type_id: str, option_id: str, **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """Update an issue property option.""" - try: - # Create the patched property option request object - patched_option_request = PatchedIssuePropertyOptionAPIRequest(**kwargs) + ) -> Dict[str, Any]: + """Update an issue property option (v0.2 - not yet implemented).""" + raise NotImplementedError("update_issue_property_option not yet available in v0.2") - response = self.work_item_properties.update_issue_property_option( - project_id=project_id, - property_id=property_id, - slug=workspace_slug, - option_id=option_id, - patched_issue_property_option_api_request=patched_option_request, - ) - return self._model_to_dict(response) - except Exception as e: - log.error(f"Failed to update issue property option: {str(e)}") - raise - - def delete_issue_property_option(self, workspace_slug: str, project_id: str, property_id: str, type_id: str, option_id: str) -> bool: - """Delete an issue property option.""" - try: - self.work_item_properties.delete_issue_property_option( - project_id=project_id, property_id=property_id, slug=workspace_slug, option_id=option_id - ) - return True - except Exception as e: - log.error(f"Failed to delete issue property option: {str(e)}") - raise + def delete_issue_property_option(self, workspace_slug: str, project_id: str, property_id: str, type_id: str, option_id: str) -> Dict[str, Any]: + """Delete an issue property option (v0.2 - not yet implemented).""" + raise NotImplementedError("delete_issue_property_option not yet available in v0.2") # ============================================================================ # TYPES API METHODS # ============================================================================ - def create_issue_type(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Create an issue type.""" + def create_issue_type(self, workspace_slug: str, project_id: str, **kwargs) -> Dict[str, Any]: + """Create an issue type (v0.2).""" try: - # Create the type request object - type_request = IssueTypeAPIRequest(**kwargs) - - response = self.work_item_types.create_issue_type(project_id=project_id, slug=workspace_slug, issue_type_api_request=type_request) - return self._model_to_dict(response) + payload = {k: v for k, v in kwargs.items() if v is not None} + resp = self.client.work_item_types.create(workspace_slug, project_id, data=CreateWorkItemType(**payload)) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to create issue type: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to create issue type: {str(e)}") raise - def list_issue_types(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List issue types.""" + def list_issue_types(self, workspace_slug: str, project_id: str) -> Dict[str, Any]: + """List issue types (v0.2).""" try: - response = self.work_item_types.list_issue_types(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + response = self.client.work_item_types.list(workspace_slug, project_id) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list issue types: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list issue types: {str(e)}") raise - def retrieve_issue_type(self, workspace_slug: str, project_id: str, type_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Retrieve a specific issue type.""" + def retrieve_issue_type(self, workspace_slug: str, project_id: str, type_id: str) -> Dict[str, Any]: + """Retrieve a specific issue type (v0.2).""" try: - response = self.work_item_types.retrieve_issue_type(project_id=project_id, slug=workspace_slug, type_id=type_id, **kwargs) - return self._model_to_dict(response) + resp = self.client.work_item_types.retrieve(workspace_slug, project_id, type_id) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to retrieve issue type: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to retrieve issue type: {str(e)}") raise - def update_issue_type(self, workspace_slug: str, project_id: str, type_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Update an issue type.""" + def update_issue_type(self, workspace_slug: str, project_id: str, type_id: str, **kwargs) -> Dict[str, Any]: + """Update an issue type (v0.2).""" try: - # Create the patched type request object - patched_type_request = PatchedIssueTypeAPIRequest(**kwargs) - - response = self.work_item_types.update_issue_type( - project_id=project_id, slug=workspace_slug, type_id=type_id, patched_issue_type_api_request=patched_type_request - ) - return self._model_to_dict(response) + payload = {k: v for k, v in kwargs.items() if v is not None} + resp = self.client.work_item_types.update(workspace_slug, project_id, type_id, data=UpdateWorkItemType(**payload)) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to update issue type: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to update issue type: {str(e)}") raise - def delete_issue_type(self, workspace_slug: str, project_id: str, type_id: str) -> bool: - """Delete an issue type.""" + def delete_issue_type(self, workspace_slug: str, project_id: str, type_id: str) -> Dict[str, Any]: + """Delete an issue type (v0.2).""" try: - self.work_item_types.delete_issue_type(project_id=project_id, slug=workspace_slug, type_id=type_id) - return True + self.client.work_item_types.delete(workspace_slug, project_id, type_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete issue type: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to delete issue type: {str(e)}") raise @@ -1872,63 +1932,74 @@ class PlaneSDKAdapter: # WORKLOGS API METHODS # ============================================================================ - def create_issue_worklog(self, workspace_slug: str, project_id: str, issue_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Create an issue worklog.""" + def create_issue_worklog(self, workspace_slug: str, project_id: str, issue_id: str, **kwargs) -> Dict[str, Any]: + """Create an issue worklog (v0.2).""" try: - # Create the worklog request object - worklog_request = IssueWorkLogAPIRequest(**kwargs) - - response = self.work_item_worklogs.create_issue_worklog( - issue_id=issue_id, project_id=project_id, slug=workspace_slug, issue_work_log_api_request=worklog_request - ) - return self._model_to_dict(response) + payload = {k: v for k, v in kwargs.items() if v is not None} + resp = self.client.work_items.work_logs.create(workspace_slug, project_id, work_item_id=issue_id, data=payload) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to create issue worklog: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to create issue worklog: {str(e)}") raise - def list_issue_worklogs(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """List issue worklogs.""" + def list_issue_worklogs(self, workspace_slug: str, project_id: str, issue_id: Optional[str] = None) -> Dict[str, Any]: + """List issue worklogs (v0.2).""" try: - response = self.work_item_worklogs.list_issue_worklogs(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + if issue_id: + response = self.client.work_items.work_logs.list(workspace_slug, project_id, work_item_id=issue_id) + else: + # Best-effort: try project-level worklogs list + response = self.client.work_items.work_logs.list(workspace_slug, project_id, work_item_id="") + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list issue worklogs: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to list issue worklogs: {str(e)}") raise - def get_project_worklog_summary(self, workspace_slug: str, project_id: str, **kwargs) -> Union[Dict[str, Any], List[Any], Any]: - """Get project worklog summary.""" + def get_project_worklog_summary(self, workspace_slug: str, project_id: str) -> Dict[str, Any]: + """Get project worklog summary (v0.2 - not implemented yet; placeholder).""" try: - response = self.work_item_worklogs.get_project_worklog_summary(project_id=project_id, slug=workspace_slug, **kwargs) - return self._model_to_dict(response) + # This method may not exist in v0.2 SDK; mark as NotImplemented for now + raise NotImplementedError("get_project_worklog_summary not yet available in v0.2") except Exception as e: log.error(f"Failed to get project worklog summary: {str(e)}") raise - def update_issue_worklog( - self, workspace_slug: str, project_id: str, issue_id: str, worklog_id: str, **kwargs - ) -> Union[Dict[str, Any], List[Any], Any]: - """Update an issue worklog.""" + def update_issue_worklog(self, workspace_slug: str, project_id: str, issue_id: str, worklog_id: str, **kwargs) -> Dict[str, Any]: + """Update an issue worklog (v0.2).""" try: - # Create the patched worklog request object - patched_worklog_request = PatchedIssueWorkLogAPIRequest(**kwargs) - - response = self.work_item_worklogs.update_issue_worklog( - issue_id=issue_id, - pk=worklog_id, - project_id=project_id, - slug=workspace_slug, - patched_issue_work_log_api_request=patched_worklog_request, - ) - return self._model_to_dict(response) + payload = {k: v for k, v in kwargs.items() if v is not None} + resp = self.client.work_items.work_logs.update(workspace_slug, project_id, work_item_id=issue_id, work_log_id=worklog_id, data=payload) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to update issue worklog: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to update issue worklog: {str(e)}") raise - def delete_issue_worklog(self, workspace_slug: str, project_id: str, issue_id: str, worklog_id: str) -> bool: - """Delete an issue worklog.""" + def delete_issue_worklog(self, workspace_slug: str, project_id: str, issue_id: str, worklog_id: str) -> Dict[str, Any]: + """Delete an issue worklog (v0.2).""" try: - self.work_item_worklogs.delete_issue_worklog(issue_id=issue_id, pk=worklog_id, project_id=project_id, slug=workspace_slug) - return True + self.client.work_items.work_logs.delete(workspace_slug, project_id, work_item_id=issue_id, work_log_id=worklog_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete issue worklog: {e} ({getattr(e, "status_code", None)})") + raise except Exception as e: log.error(f"Failed to delete issue worklog: {str(e)}") raise diff --git a/apps/pi/pi/services/actions/prompt_followup.py b/apps/pi/pi/services/actions/prompt_followup.py index 1cf1758295..1d6d6e1a20 100644 --- a/apps/pi/pi/services/actions/prompt_followup.py +++ b/apps/pi/pi/services/actions/prompt_followup.py @@ -32,6 +32,7 @@ from pi.app.models.enums import MessageMetaStepType from pi.services.actions.method_executor import MethodExecutor from pi.services.actions.plane_actions_executor import PlaneActionsExecutor from pi.services.chat.chat import PlaneChatBot +from pi.services.chat.prompts import plane_context from pi.services.retrievers.pg_store.action_artifact import add_query_to_artifact from pi.services.retrievers.pg_store.action_artifact import get_artifact_prompt_history_from_flow_steps @@ -122,7 +123,7 @@ class EntityResolver: return tool_results - def _extract_entity_info(self, tool_name: str, tool_args: Dict[str, Any], result: str) -> Dict[str, Any]: + def _extract_entity_info(self, tool_name: str, tool_args: Dict[str, Any], result: Any) -> Dict[str, Any]: """Extract entity information using standardized mapping.""" entity_type = TOOL_ENTITY_MAPPING.get(tool_name) if not entity_type: @@ -147,8 +148,28 @@ class EntityResolver: return {result_key: entity_id, name_key: entity_name} - def _extract_entity_id_from_result(self, result: str) -> Optional[str]: + def _extract_entity_id_from_result(self, result: Any) -> Optional[str]: """Extract entity ID from tool result using existing patterns.""" + # Handle structured dict payload (new format) - highest priority + if isinstance(result, dict): + # Check entity field first + if "entity" in result and isinstance(result["entity"], dict): + entity = result["entity"] + if "entity_id" in entity: + return str(entity["entity_id"]) + # Check data field + if "data" in result and isinstance(result["data"], dict): + data = result["data"] + if "id" in data: + return str(data["id"]) + # Check if result itself has entity_id + if "entity_id" in result: + return str(result["entity_id"]) + # Check if result itself has id + if "id" in result: + return str(result["id"]) + + # Handle string results (legacy format) result_str = str(result) # Look for UUID patterns (standard Plane format) @@ -314,6 +335,8 @@ class ArtifactFollowupService: """Execute LLM with search tools for entity resolution.""" try: # Bind tools to LLM (same pattern as main flow) + # deduplicate search tools + search_tools = list({tool.name: tool for tool in search_tools}.values()) llm_with_tools = self.chatbot.tool_llm.bind_tools(search_tools) llm_with_tools.set_tracking_context(message_id, db, MessageMetaStepType.ARTIFACT_MODIFICATION) @@ -455,22 +478,13 @@ class ArtifactFollowupService: ) -ARTIFACT_MODIFICATION_PROMPT = """# Plane Artifact Modification Assistant +ARTIFACT_MODIFICATION_PROMPT = f"""# Plane Artifact Modification Assistant You are a specialized AI assistant for modifying Plane project management artifacts. Your role is to process user requests and generate precise JSON modifications. -## System Context - -Plane is an enterprise project management platform with these core entities: -- **Workspaces**: Top-level organizational containers -- **Projects**: Collections of work items, cycles, and modules -- **Work Items**: Core units of work with customizable properties -- **States**: Workflow stages (Backlog, Unstarted, Started, Completed, Cancelled) -- **Cycles**: Time-boxed development sprints -- **Modules**: Logical feature groupings -- **Labels**: Categorization tags -- **Users**: Team members who can be assigned to work +## Context about Plane +{plane_context} ## Your Task @@ -539,18 +553,17 @@ When user references "me", "myself", "assign to me", or "assign it to me": After executing any necessary search tools, provide a complete JSON object with all modifications: ```json -{ - "field_name": "value", +{{"field_name": "value", "relation_field_ids": ["uuid1", "uuid2"], "single_relation_id": "uuid" -} +}} ``` ## Examples **Simple Assignment:** Request: "assign it to me" -Response: `{"assignee_ids": ["actual-user-uuid-here"]}` (NOT "me") +Response: `{{"assignee_ids": ["actual-user-uuid-here"]}}` (NOT "me") **Mixed Operations:** Request: "set state to done, priority to high, assign to me, add dev label" @@ -560,12 +573,11 @@ Process: 3. Use current user ID for self-assignment 4. Response: ```json -{ - "state_id": "state-uuid", +{{"state_id": "state-uuid", "priority": "high", "assignee_ids": ["actual-user-uuid-here"], "label_ids": ["label-uuid"] -} +}} ``` ## Critical Requirements diff --git a/apps/pi/pi/services/actions/registry.py b/apps/pi/pi/services/actions/registry.py index 7ab9bce6c8..487e136468 100644 --- a/apps/pi/pi/services/actions/registry.py +++ b/apps/pi/pi/services/actions/registry.py @@ -44,7 +44,7 @@ API_METHODS: Dict[str, Dict[str, str]] = { "projects": { "create": "Create a new project", "list": "List projects in workspace", - # "retrieve": "Get a single project by ID", + "retrieve": "Get details of a single project", "update": "Update project details", "delete": "Delete a project", "archive": "Archive a project", @@ -96,14 +96,6 @@ API_METHODS: Dict[str, Dict[str, str]] = { "create_project_page": "Create a new page in a project", "create_workspace_page": "Create a new page in the workspace", }, - "assets": { - "create": "Create a new asset", - "create_user_upload": "Upload user-specific assets", - "get_generic": "Retrieve generic assets", - "update_generic": "Update generic assets", - "update_user": "Update user assets", - "delete_user": "Delete user assets", - }, "users": { "get_current": "Get current user information", }, @@ -222,7 +214,7 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = { "add_work_items": "add_cycle_work_items", "list_work_items": "list_cycle_work_items", "retrieve_work_item": "retrieve_cycle_work_item", - "remove_work_item": "delete_cycle_work_item", + "remove_work_item": "remove_cycle_work_item", "transfer_work_items": "transfer_cycle_work_items", "delete": "delete_cycle", }, @@ -250,7 +242,7 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = { "list_archived": "list_archived_modules", "add_work_items": "add_module_work_items", "list_work_items": "list_module_work_items", - "remove_work_item": "delete_module_work_item", + "remove_work_item": "remove_module_work_item", "delete": "delete_module", }, "pages": { @@ -335,6 +327,18 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = { } +# tool_name to category mapping +def build_tool_name_to_category_map() -> Dict[str, str]: + tool_name_to_category_map = {} + for category, methods in METHOD_NAME_MAP.items(): + for method_name in methods.values(): + tool_name_to_category_map[method_name] = category + return tool_name_to_category_map + + +TOOL_NAME_TO_CATEGORY_MAP = build_tool_name_to_category_map() + + def get_method_name_map(category: str) -> Dict[str, str]: """Return the simplified→actual method mapping for a category (empty if none).""" return METHOD_NAME_MAP.get(category, {}).copy() diff --git a/apps/pi/pi/services/actions/tools/__init__.py b/apps/pi/pi/services/actions/tools/__init__.py index 48cb274860..6ee5eaea2b 100644 --- a/apps/pi/pi/services/actions/tools/__init__.py +++ b/apps/pi/pi/services/actions/tools/__init__.py @@ -35,23 +35,41 @@ from .worklogs import get_worklog_tools CATEGORY_TO_PROVIDER: Dict[str, Callable] = { "activity": get_activity_tools, + "activities": get_activity_tools, "assets": get_asset_tools, + "asset": get_asset_tools, "attachments": get_attachment_tools, + "attachment": get_attachment_tools, "comments": get_comment_tools, + "comment": get_comment_tools, "cycles": get_cycle_tools, + "cycle": get_cycle_tools, "intake": get_intake_tools, + "intakes": get_intake_tools, "labels": get_label_tools, + "label": get_label_tools, "links": get_link_tools, + "link": get_link_tools, "members": get_member_tools, + "member": get_member_tools, "modules": get_module_tools, + "module": get_module_tools, "pages": get_page_tools, + "page": get_page_tools, "projects": get_project_tools, + "project": get_project_tools, "properties": get_property_tools, + "property": get_property_tools, "states": get_state_tools, + "state": get_state_tools, "types": get_type_tools, + "type": get_type_tools, "users": get_user_tools, + "user": get_user_tools, "workitems": get_workitem_tools, + "workitem": get_workitem_tools, "worklogs": get_worklog_tools, + "worklog": get_worklog_tools, } diff --git a/apps/pi/pi/services/actions/tools/activity.py b/apps/pi/pi/services/actions/tools/activity.py index c4bbefcaed..04df09849b 100644 --- a/apps/pi/pi/services/actions/tools/activity.py +++ b/apps/pi/pi/services/actions/tools/activity.py @@ -2,6 +2,8 @@ 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 @@ -16,7 +18,7 @@ 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) -> str: + async def activity_list(project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """List activity for a project. Args: @@ -31,16 +33,16 @@ def get_activity_tools(method_executor, context): result = await method_executor.execute("activity", "list", project_id=project_id, workspace_slug=workspace_slug) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved activity list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved activity list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list activity", result["error"]) + 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, - ) -> str: + ) -> 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: @@ -56,8 +58,8 @@ def get_activity_tools(method_executor, context): workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved activity", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved activity", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to retrieve activity", result["error"]) + return PlaneToolBase.format_error_payload("Failed to retrieve activity", result["error"]) return [activity_list, activity_retrieve] diff --git a/apps/pi/pi/services/actions/tools/assets.py b/apps/pi/pi/services/actions/tools/assets.py index 7bad62b573..b60a5c775a 100644 --- a/apps/pi/pi/services/actions/tools/assets.py +++ b/apps/pi/pi/services/actions/tools/assets.py @@ -2,6 +2,8 @@ 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 @@ -16,7 +18,7 @@ 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) -> str: + async def assets_create(project_id: Optional[str] = None, workspace_slug: Optional[str] = None, **kwargs) -> Dict[str, Any]: """Create a new generic asset. Args: @@ -31,12 +33,12 @@ def get_asset_tools(method_executor, context): result = await method_executor.execute("assets", "create", project_id=project_id, workspace_slug=workspace_slug, **kwargs) if result["success"]: - return PlaneToolBase.format_success_response("Successfully created asset", result["data"]) + return PlaneToolBase.format_success_payload("Successfully created asset", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to create asset", result["error"]) + 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) -> str: + 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: @@ -46,16 +48,16 @@ def get_asset_tools(method_executor, context): 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_response("Successfully created user asset upload", result["data"]) + return PlaneToolBase.format_success_payload("Successfully created user asset upload", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to create user asset upload", result["error"]) + 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, - ) -> str: + ) -> Dict[str, Any]: """Retrieve generic assets.""" # Auto-fill from context if not provided if workspace_slug is None and "workspace_slug" in context: @@ -65,12 +67,14 @@ def get_asset_tools(method_executor, context): 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_response("Successfully retrieved generic asset", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved generic asset", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to get generic asset", result["error"]) + 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) -> str: + 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: @@ -82,12 +86,12 @@ def get_asset_tools(method_executor, context): "assets", "update_generic", asset_id=asset_id, project_id=project_id, workspace_slug=workspace_slug, **kwargs ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully updated generic asset", result["data"]) + return PlaneToolBase.format_success_payload("Successfully updated generic asset", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to update generic asset", result["error"]) + 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) -> str: + 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: @@ -99,16 +103,16 @@ def get_asset_tools(method_executor, context): "assets", "update_user", asset_id=asset_id, project_id=project_id, workspace_slug=workspace_slug, **kwargs ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully updated user asset", result["data"]) + return PlaneToolBase.format_success_payload("Successfully updated user asset", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to update user asset", result["error"]) + 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, - ) -> str: + ) -> Dict[str, Any]: """Delete user assets.""" # Auto-fill from context if not provided if workspace_slug is None and "workspace_slug" in context: @@ -118,8 +122,8 @@ def get_asset_tools(method_executor, context): 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_response("Successfully deleted user asset", result["data"]) + return PlaneToolBase.format_success_payload("Successfully deleted user asset", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to delete user asset", result["error"]) + 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] diff --git a/apps/pi/pi/services/actions/tools/attachments.py b/apps/pi/pi/services/actions/tools/attachments.py index 5bcdd710db..9cc826caf6 100644 --- a/apps/pi/pi/services/actions/tools/attachments.py +++ b/apps/pi/pi/services/actions/tools/attachments.py @@ -2,6 +2,8 @@ Attachments API tools for Plane file attachment operations. """ +from typing import Any +from typing import Dict from typing import Optional from langchain_core.tools import tool @@ -19,16 +21,26 @@ def get_attachment_tools(method_executor, context): async def attachments_create( issue_id: str, asset: str, + name: str, + size: int, + file_type: Optional[str] = None, + external_id: Optional[str] = None, + external_source: Optional[str] = None, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Create a new attachment on work item. Args: - issue_id: Parameter description (required) - asset: Parameter description (required) - project_id: Parameter description (optional) - workspace_slug: Parameter description (optional) + issue_id: UUID of the work item to attach file to (required) + asset: Asset identifier or URL (required) + name: Original filename of the asset (required) + size: File size in bytes (required) + file_type: MIME type of the file (optional) + external_id: External identifier for the asset (optional) + external_source: External source system (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: @@ -41,17 +53,28 @@ def get_attachment_tools(method_executor, context): "create", issue_id=issue_id, asset=asset, + name=name, + size=size, + type=file_type, + external_id=external_id, + external_source=external_source, project_id=project_id, workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully created attachment", result["data"]) + return PlaneToolBase.format_success_payload("Successfully created attachment", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to create attachment", result["error"]) + return PlaneToolBase.format_error_payload("Failed to create attachment", result["error"]) @tool - async def attachments_list(issue_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> str: - """List attachments for an issue.""" + async def attachments_list(issue_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: + """List attachments for a work item. + + Args: + issue_id: UUID of the work item (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"] @@ -60,17 +83,25 @@ def get_attachment_tools(method_executor, context): result = await method_executor.execute("attachments", "list", issue_id=issue_id, project_id=project_id, workspace_slug=workspace_slug) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved attachments list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved attachments list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list attachments", result["error"]) + return PlaneToolBase.format_error_payload("Failed to list attachments", result["error"]) @tool async def attachments_retrieve( attachment_id: str, + issue_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: - """Get a single attachment by ID.""" + ) -> Dict[str, Any]: + """Get a single attachment by ID. + + Args: + attachment_id: UUID of the attachment (required) + issue_id: UUID of the work item (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"] @@ -81,21 +112,67 @@ def get_attachment_tools(method_executor, context): "attachments", "retrieve", attachment_id=attachment_id, + issue_id=issue_id, project_id=project_id, workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved attachment", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved attachment", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to retrieve attachment", result["error"]) + return PlaneToolBase.format_error_payload("Failed to retrieve attachment", result["error"]) + + @tool + async def attachments_update( + attachment_id: str, + issue_id: str, + is_uploaded: Optional[bool] = None, + project_id: Optional[str] = None, + workspace_slug: Optional[str] = None, + ) -> Dict[str, Any]: + """Update an attachment for a work item. + + Args: + attachment_id: UUID of the attachment (required) + issue_id: UUID of the work item (required) + is_uploaded: Mark attachment as uploaded (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"] + + result = await method_executor.execute( + "attachments", + "update", + attachment_id=attachment_id, + issue_id=issue_id, + is_uploaded=is_uploaded, + project_id=project_id, + workspace_slug=workspace_slug, + ) + if result["success"]: + return PlaneToolBase.format_success_payload("Successfully updated attachment", result["data"]) + else: + return PlaneToolBase.format_error_payload("Failed to update attachment", result["error"]) @tool async def attachments_delete( attachment_id: str, + issue_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: - """Delete an attachment.""" + ) -> Dict[str, Any]: + """Delete an attachment. + + Args: + attachment_id: UUID of the attachment (required) + issue_id: UUID of the work item (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"] @@ -106,12 +183,13 @@ def get_attachment_tools(method_executor, context): "attachments", "delete", attachment_id=attachment_id, + issue_id=issue_id, project_id=project_id, workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully deleted attachment", result["data"]) + return PlaneToolBase.format_success_payload("Successfully deleted attachment", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to delete attachment", result["error"]) + return PlaneToolBase.format_error_payload("Failed to delete attachment", result["error"]) - return [attachments_create, attachments_list, attachments_retrieve, attachments_delete] + return [attachments_create, attachments_list, attachments_retrieve, attachments_update, attachments_delete] diff --git a/apps/pi/pi/services/actions/tools/base.py b/apps/pi/pi/services/actions/tools/base.py index e9bc3e9223..6238d3993e 100644 --- a/apps/pi/pi/services/actions/tools/base.py +++ b/apps/pi/pi/services/actions/tools/base.py @@ -6,6 +6,7 @@ import time from typing import Any from typing import Dict from typing import Optional +from typing import Tuple from pi import logger @@ -41,6 +42,25 @@ class PlaneToolBase: """Format successful operation response.""" return f"✅ {message}\n\nResult: {data}" + @staticmethod + def format_success_payload( + message: str, data: Any = None, entity: Optional[Dict[str, Any]] = None, meta: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Structured success payload for API/consumers. Intended long-term replacement for string responses. + """ + payload: Dict[str, Any] = { + "ok": True, + "message": f"✅ {message}", + } + if entity is not None: + payload["entity"] = entity + if data is not None: + payload["data"] = data + if meta is not None: + payload["meta"] = meta + return payload + @staticmethod async def format_success_response_with_url(message: str, data: Any, entity_type: str, context: Dict[str, Any]) -> str: """ @@ -127,11 +147,75 @@ class PlaneToolBase: # Final fallback to basic response if entity extraction completely fails return PlaneToolBase.format_success_response(message, data) + @staticmethod + async def format_success_payload_with_url(message: str, data: Any, entity_type: str, context: Dict[str, Any]) -> Dict[str, Any]: + """ + 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.config import settings as _settings + + entity: Dict[str, Any] = {} + try: + entity_data = extract_entity_from_api_response(data, entity_type) + if entity_data: + workspace_slug = context.get("workspace_slug") + frontend_url = _settings.plane_api.FRONTEND_URL + if context.get("workspace_id"): + entity_data["workspace"] = str(context["workspace_id"]) + if workspace_slug: + try: + from pi.agents.sql_agent.tools import construct_action_entity_url + + url_info = await construct_action_entity_url(entity_data, entity_type, workspace_slug, frontend_url) + if url_info: + # Normalize keys to entity_* for consumers + entity["entity_url"] = url_info.get("entity_url") + entity["entity_name"] = url_info.get("entity_name") + entity["entity_type"] = url_info.get("entity_type") or entity_type + entity["entity_id"] = url_info.get("entity_id") + # Carry identifier fields where applicable + if url_info.get("issue_identifier"): + entity["issue_identifier"] = url_info["issue_identifier"] + if url_info.get("entity_identifier"): + entity["entity_identifier"] = url_info["entity_identifier"] + except Exception as e: + log.error(f"Error constructing entity URL: {e}") + # Ensure id/name/type present even if URL creation failed + if entity_data.get("id") and "entity_id" not in entity: + entity["entity_id"] = str(entity_data["id"]) + if entity_data.get("name") and "entity_name" not in entity: + entity["entity_name"] = str(entity_data["name"]) + if "entity_type" not in entity: + entity["entity_type"] = entity_type + except Exception as e: + log.warning(f"Failed to build structured entity payload: {e}") + + return PlaneToolBase.format_success_payload(message, data=data, entity=entity or None) + @staticmethod def format_error_response(message: str, error: Any) -> str: """Format error response.""" return f"❌ {message}\n\nError: {error}" + @staticmethod + def format_error_payload(message: str, error: Any = None, data: Any = None, meta: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Structured error payload for API/consumers. Intended long-term replacement for string responses. + """ + payload: Dict[str, Any] = { + "ok": False, + "message": f"❌ {message}", + } + if error is not None: + payload["error"] = error + if data is not None: + payload["data"] = data + if meta is not None: + payload["meta"] = meta + return payload + @staticmethod def generate_project_identifier(name: str) -> str: """Generate a project identifier from name.""" @@ -143,10 +227,12 @@ class PlaneToolBase: return base_identifier @staticmethod - def generate_fallback_identifier(base_identifier: str) -> str: - """Generate fallback identifier with timestamp.""" - timestamp = str(int(time.time()))[-3:] # Last 3 digits of timestamp - return f"{base_identifier}{timestamp}" + def generate_fallback_name_identifier(base_name: str, base_identifier: str) -> Tuple[str, str]: + """Generate fallback name and identifier with timestamp.""" + timestamp = str(int(time.time()))[-5:] # Last 5 digits of timestamp + name = f"{base_name}{timestamp}" + identifier = f"{base_identifier}{timestamp}" + return name, identifier def get_workspace_slug_from_context(context: Dict[str, Any]) -> Optional[str]: diff --git a/apps/pi/pi/services/actions/tools/comments.py b/apps/pi/pi/services/actions/tools/comments.py index 76b908a2ee..7581ab6e8c 100644 --- a/apps/pi/pi/services/actions/tools/comments.py +++ b/apps/pi/pi/services/actions/tools/comments.py @@ -2,6 +2,8 @@ Comments API tools for Plane issue comment management. """ +from typing import Any +from typing import Dict from typing import Optional from langchain_core.tools import tool @@ -21,15 +23,20 @@ def get_comment_tools(method_executor, context): comment_html: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: + external_source: Optional[str] = None, + external_id: Optional[str] = None, + ) -> Dict[str, Any]: """Create a comment on an issue. Args: - issue_id: Parameter description (required) - comment_html: Parameter description (required) - project_id: Parameter description (optional) - workspace_slug: Parameter description (optional) + issue_id: Issue ID (required) + comment_html: Comment content in HTML format (required) + project_id: Project ID (optional, auto-filled from context) + 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"] @@ -41,16 +48,18 @@ def get_comment_tools(method_executor, context): "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"]: - return PlaneToolBase.format_success_response("Successfully created comment", result["data"]) + return PlaneToolBase.format_success_payload("Successfully created comment", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to create comment", result["error"]) + 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) -> str: + 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: @@ -60,17 +69,25 @@ def get_comment_tools(method_executor, context): 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_response("Successfully retrieved comments list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved comments list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list comments", result["error"]) + 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, - ) -> str: - """Get a single comment by ID.""" + ) -> 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) + 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"] @@ -81,22 +98,36 @@ def get_comment_tools(method_executor, context): "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_response("Successfully retrieved comment", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved comment", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to retrieve comment", result["error"]) + return PlaneToolBase.format_error_payload("Failed to retrieve comment", result["error"]) @tool async def comments_update( comment_id: str, - comment_html: str, + issue_id: str, + comment_html: Optional[str] = None, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: - """Update comment details.""" + 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) + 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"] @@ -107,22 +138,33 @@ def get_comment_tools(method_executor, context): "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"]: - return PlaneToolBase.format_success_response("Successfully updated comment", result["data"]) + return PlaneToolBase.format_success_payload("Successfully updated comment", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to update comment", result["error"]) + 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, - ) -> str: - """Delete a comment.""" + ) -> 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) + 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"] @@ -133,12 +175,13 @@ def get_comment_tools(method_executor, context): "comments", "delete", comment_id=comment_id, + issue_id=issue_id, project_id=project_id, workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully deleted comment", result["data"]) + return PlaneToolBase.format_success_payload("Successfully deleted comment", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to delete comment", result["error"]) + return PlaneToolBase.format_error_payload("Failed to delete comment", result["error"]) return [comments_create, comments_list, comments_retrieve, comments_update, comments_delete] diff --git a/apps/pi/pi/services/actions/tools/cycles.py b/apps/pi/pi/services/actions/tools/cycles.py index cf9a1ddb59..1042fcb34e 100644 --- a/apps/pi/pi/services/actions/tools/cycles.py +++ b/apps/pi/pi/services/actions/tools/cycles.py @@ -2,6 +2,8 @@ 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 @@ -26,19 +28,22 @@ def get_cycle_tools(method_executor, context): owned_by: Optional[str] = None, external_id: Optional[str] = None, external_source: Optional[str] = None, - ) -> str: + ) -> 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 (optional) - end_date: End date in YYYY-MM-DD format (optional) + 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: @@ -59,15 +64,14 @@ def get_cycle_tools(method_executor, context): end_date=end_date, description=description, owned_by=owned_by, - user_id=context.get("user_id"), external_id=external_id, external_source=external_source, ) if result["success"]: - return await PlaneToolBase.format_success_response_with_url(f"Successfully created cycle '{name}'", result["data"], "cycle", context) + return await PlaneToolBase.format_success_payload_with_url(f"Successfully created cycle '{name}'", result["data"], "cycle", context) else: - return PlaneToolBase.format_error_response("Failed to create cycle", result["error"]) + return PlaneToolBase.format_error_payload("Failed to create cycle", result["error"]) @tool async def cycles_list( @@ -76,7 +80,7 @@ def get_cycle_tools(method_executor, context): per_page: Optional[int] = 20, cursor: Optional[str] = None, cycle_view: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """List cycles in a project. Args: @@ -100,7 +104,7 @@ def get_cycle_tools(method_executor, context): # Validate required parameters after auto-fill if not project_id or not workspace_slug: - return PlaneToolBase.format_error_response( + return PlaneToolBase.format_error_payload( "Failed to list cycles", "Missing required context: project_id/workspace_slug", ) @@ -116,31 +120,31 @@ def get_cycle_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved cycles list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved cycles list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list cycles", result["error"]) + return PlaneToolBase.format_error_payload("Failed to list cycles", result["error"]) @tool - async def cycles_retrieve(pk: str, project_id: str, workspace_slug: str) -> str: + async def cycles_retrieve(cycle_id: str, project_id: str, workspace_slug: str) -> Dict[str, Any]: """Retrieve a single cycle by ID. Args: - pk: Cycle ID (required) + 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", pk=pk, project_id=project_id, workspace_slug=workspace_slug) + 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_response("Successfully retrieved cycle", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved cycle", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to retrieve cycle", result["error"]) + return PlaneToolBase.format_error_payload("Failed to retrieve cycle", result["error"]) @tool async def cycles_update( - pk: str, + cycle_id: str, project_id: str, workspace_slug: Optional[str] = None, name: Optional[str] = None, @@ -150,11 +154,11 @@ def get_cycle_tools(method_executor, context): owned_by: Optional[str] = None, external_id: Optional[str] = None, external_source: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Update cycle details. Args: - pk: Cycle ID (required) + 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 @@ -186,15 +190,17 @@ def get_cycle_tools(method_executor, context): if external_source is not None: update_data["external_source"] = external_source - result = await method_executor.execute("cycles", "update", pk=pk, project_id=project_id, workspace_slug=workspace_slug, **update_data) + 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_response_with_url("Successfully updated cycle", result["data"], "cycle", context) + return await PlaneToolBase.format_success_payload_with_url("Successfully updated cycle", result["data"], "cycle", context) else: - return PlaneToolBase.format_error_response("Failed to update cycle", result["error"]) + 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) -> str: + async def cycles_archive(cycle_id: str, project_id: str, workspace_slug: str) -> Dict[str, Any]: """Archive a cycle. Args: @@ -204,15 +210,15 @@ def get_cycle_tools(method_executor, context): """ # project_id and workspace_slug are now required parameters - result = await method_executor.execute("cycles", "archive", pk=cycle_id, project_id=project_id, workspace_slug=workspace_slug) + 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_response("Successfully archived cycle", result["data"]) + return PlaneToolBase.format_success_payload("Successfully archived cycle", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to archive cycle", result["error"]) + 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) -> str: + 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: @@ -227,7 +233,7 @@ def get_cycle_tools(method_executor, context): workspace_slug = context["workspace_slug"] # Check if issues list is empty if not issues or len(issues) == 0: - return PlaneToolBase.format_success_response( + 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} ) @@ -237,12 +243,12 @@ def get_cycle_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully added work items to cycle", result["data"]) + return PlaneToolBase.format_success_payload("Successfully added work items to cycle", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to add work items to cycle", result["error"]) + 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) -> str: + 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: @@ -262,12 +268,12 @@ def get_cycle_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully removed work item from cycle", result["data"]) + return PlaneToolBase.format_success_payload("Successfully removed work item from cycle", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to remove work item from cycle", result["error"]) + 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) -> str: + 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: @@ -275,14 +281,14 @@ def get_cycle_tools(method_executor, context): workspace_slug = context["workspace_slug"] # project_id and workspace_slug are now required parameters - result = await method_executor.execute("cycles", "unarchive", pk=cycle_id, project_id=project_id, workspace_slug=workspace_slug) + 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_response(f"Successfully unarchived cycle '{cycle_id}'", result["data"]) + return PlaneToolBase.format_success_payload(f"Successfully unarchived cycle '{cycle_id}'", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to unarchive cycle", result["error"]) + 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) -> str: + 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: @@ -292,12 +298,12 @@ def get_cycle_tools(method_executor, context): # 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_response("Successfully retrieved archived cycles list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved archived cycles list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list archived cycles", result["error"]) + 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) -> str: + 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: @@ -307,12 +313,12 @@ def get_cycle_tools(method_executor, context): # 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_response("Successfully retrieved cycle work items list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved cycle work items list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list cycle work items", result["error"]) + 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) -> str: + 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: @@ -324,12 +330,12 @@ def get_cycle_tools(method_executor, context): "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_response("Successfully retrieved cycle work item", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved cycle work item", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to retrieve cycle work item", result["error"]) + 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) -> str: + 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: @@ -341,23 +347,13 @@ def get_cycle_tools(method_executor, context): "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_response( + 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_response("Failed to transfer cycle work items", result["error"]) + return PlaneToolBase.format_error_payload("Failed to transfer cycle work items", result["error"]) - # Get entity search tools relevant only to cycles - from .entity_search import get_entity_search_tools - - entity_search_tools = get_entity_search_tools(method_executor, context) - cycle_entity_search_tools = [ - t - for t in entity_search_tools - if getattr(t, "name", "").find("cycle") != -1 or getattr(t, "name", "").find("user") != -1 or getattr(t, "name", "") == "search_current_cycle" - ] - - return cycle_entity_search_tools + [ + return [ cycles_create, cycles_update, cycles_archive, diff --git a/apps/pi/pi/services/actions/tools/entity_search.py b/apps/pi/pi/services/actions/tools/entity_search.py index dd5ceca51c..9d357b279a 100644 --- a/apps/pi/pi/services/actions/tools/entity_search.py +++ b/apps/pi/pi/services/actions/tools/entity_search.py @@ -4,10 +4,13 @@ Provides search functionality for all major entities using database queries. """ import uuid +from typing import Any +from typing import Dict from typing import Optional from langchain_core.tools import tool +from pi import settings from pi.core.db.plane import PlaneDBPool from .base import PlaneToolBase @@ -42,7 +45,7 @@ def get_entity_search_tools(method_executor, context): return str(row["id"]) if row else None @tool - async def search_module_by_name(name: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> str: + async def search_module_by_name(name: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """Search for a module by name and return its ID. Args: @@ -64,17 +67,17 @@ def get_entity_search_tools(method_executor, context): result = await search_module_by_name(name, project_id, workspace_slug) if result: - return PlaneToolBase.format_success_response( + return PlaneToolBase.format_success_payload( f"Found module '{name}'", {"id": result["id"], "name": result["name"], "project_id": result["project_id"]} ) else: - return PlaneToolBase.format_error_response(f"No module found with name '{name}'", "Not found") + return PlaneToolBase.format_success_payload(f"No module found with name '{name}'", "Not found") except Exception as e: - return PlaneToolBase.format_error_response(f"Error searching for module '{name}': {str(e)}", str(e)) + return PlaneToolBase.format_error_payload(f"Error searching for module '{name}': {str(e)}", str(e)) @tool - async def list_member_projects(workspace_id: Optional[str] = None, user_id: Optional[str] = None, limit: Optional[int] = 50) -> str: + async def list_member_projects(workspace_id: Optional[str] = None, user_id: Optional[str] = None, limit: Optional[int] = 50) -> Dict[str, Any]: """List active projects the current user is a member of (archived/deleted excluded). Args: @@ -90,7 +93,7 @@ def get_entity_search_tools(method_executor, context): try: if not workspace_id or not user_id: - return PlaneToolBase.format_error_response( + return PlaneToolBase.format_error_payload( "Failed to list projects", "Missing workspace_id/user_id in context", ) @@ -109,7 +112,7 @@ def get_entity_search_tools(method_executor, context): ORDER BY p.name LIMIT $3 """ - rows = await PlaneDBPool.fetch(query, (workspace_id, user_id, int(limit or 50))) + rows = await PlaneDBPool.fetch(query, (uuid.UUID(workspace_id), uuid.UUID(user_id), int(limit or 50))) projects = [ { "id": str(r["id"]), @@ -119,12 +122,12 @@ def get_entity_search_tools(method_executor, context): } for r in rows ] - return PlaneToolBase.format_success_response("Successfully retrieved member projects", {"projects": projects, "count": len(projects)}) + return PlaneToolBase.format_success_payload("Successfully retrieved member projects", {"projects": projects, "count": len(projects)}) except Exception as e: - return PlaneToolBase.format_error_response("Failed to list member projects", str(e)) + return PlaneToolBase.format_error_payload("Failed to list member projects", str(e)) @tool - async def search_project_by_name(name: str, workspace_slug: Optional[str] = None, user_id: Optional[str] = None) -> str: + async def search_project_by_name(name: str, workspace_slug: Optional[str] = None, user_id: Optional[str] = None) -> Dict[str, Any]: """Search for a project by name. Args: @@ -167,15 +170,15 @@ def get_entity_search_tools(method_executor, context): else: message = f"Found 1 project matching '{name}'" - return PlaneToolBase.format_success_response(message, data) + return PlaneToolBase.format_success_payload(message, data) else: - return PlaneToolBase.format_error_response(f"No project found with name '{name}'", "Not found") + return PlaneToolBase.format_success_payload(f"No project found with name '{name}'", "Not found") except Exception as e: - return PlaneToolBase.format_error_response(f"Error searching for project '{name}': {str(e)}", str(e)) + return PlaneToolBase.format_error_payload(f"Error searching for project '{name}': {str(e)}", str(e)) @tool - async def search_project_by_identifier(identifier: str, workspace_slug: Optional[str] = None) -> str: + async def search_project_by_identifier(identifier: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """Search for a project by its identifier (e.g., 'HYDR', 'PARM') and return its UUID. CRITICAL: Project identifiers are short uppercase codes (like 'HYDR', 'PARM'), NOT UUIDs. @@ -212,7 +215,7 @@ def get_entity_search_tools(method_executor, context): result = await PlaneDBPool.fetchrow(query, tuple(params)) if result: - return PlaneToolBase.format_success_response( + return PlaneToolBase.format_success_payload( f"Found project with identifier '{identifier}'", { "id": str(result["id"]), @@ -222,13 +225,13 @@ def get_entity_search_tools(method_executor, context): }, ) else: - return PlaneToolBase.format_error_response(f"No project found with identifier '{identifier}'", "Not found") + return PlaneToolBase.format_success_payload(f"No project found with identifier '{identifier}'", "Not found") except Exception as e: - return PlaneToolBase.format_error_response(f"Error searching for project '{identifier}': {str(e)}", str(e)) + return PlaneToolBase.format_error_payload(f"Error searching for project '{identifier}': {str(e)}", str(e)) @tool - async def search_cycle_by_name(name: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> str: + async def search_cycle_by_name(name: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """Search for a cycle by name and return its ID. Args: @@ -255,15 +258,15 @@ def get_entity_search_tools(method_executor, context): response_data["start_date"] = result["start_date"] if result.get("end_date"): response_data["end_date"] = result["end_date"] - return PlaneToolBase.format_success_response(f"Found cycle '{name}'", response_data) + return PlaneToolBase.format_success_payload(f"Found cycle '{name}'", response_data) else: - return PlaneToolBase.format_error_response(f"No cycle found with name '{name}'", "Not found") + return PlaneToolBase.format_success_payload(f"No cycle found with name '{name}'", "Not found") except Exception as e: - return PlaneToolBase.format_error_response(f"Error searching for cycle '{name}': {str(e)}", str(e)) + return PlaneToolBase.format_error_payload(f"Error searching for cycle '{name}': {str(e)}", str(e)) @tool - async def search_current_cycle(project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> str: + async def search_current_cycle(project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """Search for the current active cycle in a project (where today's date falls within the cycle's start and end dates). IMPORTANT: Use this tool when the user asks about the "current cycle", "active cycle", "ongoing cycle", or "this cycle". @@ -281,7 +284,7 @@ def get_entity_search_tools(method_executor, context): try: if not project_id: - return PlaneToolBase.format_error_response( + return PlaneToolBase.format_error_payload( "Failed to search for current cycle", "project_id is required to search for current cycle", ) @@ -289,52 +292,64 @@ def get_entity_search_tools(method_executor, context): # Normalize project_id if an identifier like 'OGX' was passed normalized_project_id = await _normalize_project_id(project_id, workspace_slug) if not normalized_project_id: - return PlaneToolBase.format_error_response( + return PlaneToolBase.format_error_payload( "Failed to search for current cycle", "project_id is required to search for current cycle", ) from pi.app.api.v1.helpers.plane_sql_queries import search_current_cycle - result = await search_current_cycle(normalized_project_id, workspace_slug) + results = await search_current_cycle(normalized_project_id, workspace_slug) - if result: - response_data = { - "id": result["id"], - "name": result["name"], - "project_id": result["project_id"], - "start_date": result.get("start_date"), - "end_date": result.get("end_date"), - "is_current": True, - } - - # Build cycle URL and include in response message - cycle_url = None + if results: + cycles_data = [] + cycle_names = [] try: - from pi import settings - - ws_slug = result.get("workspace_slug") or workspace_slug - if ws_slug and result.get("project_id") and result.get("id"): - api_base_url = settings.plane_api.FRONTEND_URL - cycle_url = f"{api_base_url}/{ws_slug}/projects/{result["project_id"]}/cycles/{result["id"]}/" - response_data["url"] = cycle_url + api_base_url = settings.plane_api.FRONTEND_URL except Exception: - pass + api_base_url = None - # Include URL in the message text so LLM sees it even without entity_urls - message = f"Found current active cycle: '{result["name"]}'" - if cycle_url: - message += f"\nCycle URL: {cycle_url}" + for result in results: + cycle_data = { + "id": result["id"], + "name": result["name"], + "project_id": result["project_id"], + "start_date": result.get("start_date"), + "end_date": result.get("end_date"), + "is_current": True, + } - return PlaneToolBase.format_success_response(message, response_data) + # Build cycle URL and include in response message + cycle_url = None + if api_base_url: + ws_slug = result.get("workspace_slug") or workspace_slug + if ws_slug and result.get("project_id") and result.get("id"): + cycle_url = f"{api_base_url}/{ws_slug}/projects/{result["project_id"]}/cycles/{result["id"]}/" + cycle_data["url"] = cycle_url + + cycles_data.append(cycle_data) + cycle_names.append(result["name"]) + + # Build response message + if len(results) == 1: + message = f"Found current active cycle: '{cycle_names[0]}'" + if cycles_data[0].get("url"): + message += f"\nCycle URL: {cycles_data[0]["url"]}" + response_data = cycles_data[0] + else: + cycle_names_str = ", ".join([f"'{name}'" for name in cycle_names]) + message = f"Found {len(results)} current active cycles: {cycle_names_str}" + response_data = {"cycles": cycles_data, "count": len(results)} + + return PlaneToolBase.format_success_payload(message, response_data) else: - return PlaneToolBase.format_error_response( + return PlaneToolBase.format_success_payload( "No current active cycle found", "No cycle is active today (no cycle where today falls between start_date and end_date)", ) except Exception as e: - return PlaneToolBase.format_error_response(f"Error searching for current cycle: {str(e)}", str(e)) + return PlaneToolBase.format_error_payload(f"Error searching for current cycle: {str(e)}", str(e)) @tool async def list_recent_cycles( @@ -342,7 +357,7 @@ def get_entity_search_tools(method_executor, context): status: Optional[str] = "completed", project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """List recent cycles in the current project/workspace context. Use this to resolve "last cycle" or "previous cycle" in project chats. @@ -370,7 +385,7 @@ def get_entity_search_tools(method_executor, context): workspace_id = str(row["id"]) if row else None if not project_id and not workspace_id: - return PlaneToolBase.format_error_response( + return PlaneToolBase.format_error_payload( "Failed to list recent cycles", "Missing project context; provide project_id or ensure project chat context", ) @@ -388,12 +403,12 @@ def get_entity_search_tools(method_executor, context): if rows: data["last_cycle_id"] = rows[0]["id"] - return PlaneToolBase.format_success_response("Successfully retrieved recent cycles", data) + return PlaneToolBase.format_success_payload("Successfully retrieved recent cycles", data) except Exception as e: - return PlaneToolBase.format_error_response("Failed to list recent cycles", str(e)) + return PlaneToolBase.format_error_payload("Failed to list recent cycles", str(e)) @tool - async def search_label_by_name(name: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> str: + async def search_label_by_name(name: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """Search for a label by name and return its ID. Args: @@ -415,17 +430,17 @@ def get_entity_search_tools(method_executor, context): result = await search_label_by_name(name, project_id, workspace_slug) if result: - return PlaneToolBase.format_success_response( + return PlaneToolBase.format_success_payload( f"Found label '{name}'", {"id": result["id"], "name": result["name"], "project_id": result["project_id"]} ) else: - return PlaneToolBase.format_error_response(f"No label found with name '{name}'", "Not found") + return PlaneToolBase.format_success_payload(f"No label found with name '{name}'", "Not found") except Exception as e: - return PlaneToolBase.format_error_response(f"Error searching for label '{name}': {str(e)}", str(e)) + return PlaneToolBase.format_error_payload(f"Error searching for label '{name}': {str(e)}", str(e)) @tool - async def search_state_by_name(name: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> str: + async def search_state_by_name(name: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """Search for a state by name and return its ID. Args: @@ -447,14 +462,14 @@ def get_entity_search_tools(method_executor, context): result = await search_state_by_name(name, project_id, workspace_slug) if result: - return PlaneToolBase.format_success_response( + return PlaneToolBase.format_success_payload( f"Found state '{name}'", {"id": result["id"], "name": result["name"], "project_id": result["project_id"]} ) else: - return PlaneToolBase.format_error_response(f"No state found with name '{name}'", "Not found") + return PlaneToolBase.format_success_payload(f"No state found with name '{name}'", "Not found") except Exception as e: - return PlaneToolBase.format_error_response(f"Error searching for state '{name}': {str(e)}", str(e)) + return PlaneToolBase.format_error_payload(f"Error searching for state '{name}': {str(e)}", str(e)) @tool async def search_user_by_name( @@ -462,7 +477,7 @@ def get_entity_search_tools(method_executor, context): workspace_slug: Optional[str] = None, first_name: Optional[str] = None, last_name: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Search for a user by display, first, or last name and return matches. Args: @@ -478,7 +493,7 @@ def get_entity_search_tools(method_executor, context): # Ensure at least one name parameter is present if not any([display_name, first_name, last_name]): - return PlaneToolBase.format_error_response( + return PlaneToolBase.format_error_payload( "At least one of display_name, first_name, or last_name must be provided", "missing_parameters", ) @@ -535,15 +550,15 @@ def get_entity_search_tools(method_executor, context): else: message = f"Found 1 user matching '{display_name}'" - return PlaneToolBase.format_success_response(message, users_data) + return PlaneToolBase.format_success_payload(message, users_data) else: - return PlaneToolBase.format_error_response(f"No user found with display name '{display_name}'", "Not found") + return PlaneToolBase.format_success_payload(f"No user found with display name '{display_name}'", "Not found") except Exception as e: - return PlaneToolBase.format_error_response(f"Error searching for user '{display_name}': {str(e)}", str(e)) + return PlaneToolBase.format_error_payload(f"Error searching for user '{display_name}': {str(e)}", str(e)) @tool - async def search_workitem_by_name(name: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> str: + async def search_workitem_by_name(name: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """Search for a work item by name and return its ID. Args: @@ -565,7 +580,7 @@ def get_entity_search_tools(method_executor, context): result = await search_workitem_by_name(name, project_id, workspace_slug) if result: - return PlaneToolBase.format_success_response( + return PlaneToolBase.format_success_payload( f"Found work item '{name}'", { "id": result["id"], @@ -577,13 +592,13 @@ def get_entity_search_tools(method_executor, context): }, ) else: - return PlaneToolBase.format_error_response(f"No work item found with name '{name}'", "Not found") + return PlaneToolBase.format_success_payload(f"No work item found with name '{name}'", "Not found") except Exception as e: - return PlaneToolBase.format_error_response(f"Error searching for work item '{name}': {str(e)}", str(e)) + return PlaneToolBase.format_error_payload(f"Error searching for work item '{name}': {str(e)}", str(e)) @tool - async def search_workitem_by_identifier(identifier: str, workspace_slug: Optional[str] = None) -> str: + async def search_workitem_by_identifier(identifier: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """Search for a work item by its unique identifier (e.g., 'WEB-821') and return its details. Don't call this tool if the 'identifier' string is not in the format 'PROJECT-SEQUENCE'. @@ -601,7 +616,7 @@ def get_entity_search_tools(method_executor, context): result = await search_workitem_by_identifier(identifier, workspace_slug) if result: - return PlaneToolBase.format_success_response( + return PlaneToolBase.format_success_payload( f"Found work item '{identifier}'", { "id": result["id"], @@ -619,10 +634,10 @@ def get_entity_search_tools(method_executor, context): }, ) else: - return PlaneToolBase.format_error_response(f"No work item found with identifier '{identifier}'", "Not found") + return PlaneToolBase.format_success_payload(f"No work item found with identifier '{identifier}'", "Not found") except Exception as e: - return PlaneToolBase.format_error_response(f"Error searching for work item '{identifier}': {str(e)}", str(e)) + return PlaneToolBase.format_error_payload(f"Error searching for work item '{identifier}': {str(e)}", str(e)) return [ list_member_projects, diff --git a/apps/pi/pi/services/actions/tools/intake.py b/apps/pi/pi/services/actions/tools/intake.py index cf660941d9..ef3c9770e3 100644 --- a/apps/pi/pi/services/actions/tools/intake.py +++ b/apps/pi/pi/services/actions/tools/intake.py @@ -28,7 +28,7 @@ def get_intake_tools(method_executor, context): assignee: Optional[str] = None, reporter: Optional[str] = None, labels: Optional[List[str]] = None, - ) -> str: + ) -> Dict[str, Any]: """Submit work item to intake queue for triage. Args: @@ -61,9 +61,9 @@ def get_intake_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response(f"Successfully submitted intake item '{name}'", result["data"]) + return PlaneToolBase.format_success_payload(f"Successfully submitted intake item '{name}'", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to create intake item", result["error"]) + return PlaneToolBase.format_error_payload("Failed to create intake item", result["error"]) @tool async def intake_list( @@ -71,7 +71,7 @@ def get_intake_tools(method_executor, context): workspace_slug: Optional[str] = None, per_page: Optional[int] = 20, cursor: Optional[str] = None, - ) -> str: + ) -> 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: @@ -89,16 +89,16 @@ def get_intake_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved intake list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved intake list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list intake items", result["error"]) + 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, - ) -> str: + ) -> 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: @@ -115,9 +115,9 @@ def get_intake_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved intake item", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved intake item", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to retrieve intake item", result["error"]) + return PlaneToolBase.format_error_payload("Failed to retrieve intake item", result["error"]) @tool async def intake_update( @@ -130,7 +130,7 @@ def get_intake_tools(method_executor, context): assignee: Optional[str] = None, reporter: Optional[str] = None, labels: Optional[List[str]] = None, - ) -> str: + ) -> 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: @@ -163,16 +163,16 @@ def get_intake_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully updated intake item", result["data"]) + return PlaneToolBase.format_success_payload("Successfully updated intake item", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to update intake item", result["error"]) + 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, - ) -> str: + ) -> Dict[str, Any]: """Remove intake work item.""" # Auto-fill from context if not provided if workspace_slug is None and "workspace_slug" in context: @@ -189,8 +189,8 @@ def get_intake_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully deleted intake item", result["data"]) + return PlaneToolBase.format_success_payload("Successfully deleted intake item", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to delete intake item", result["error"]) + return PlaneToolBase.format_error_payload("Failed to delete intake item", result["error"]) return [intake_create, intake_list, intake_retrieve, intake_update, intake_delete] diff --git a/apps/pi/pi/services/actions/tools/labels.py b/apps/pi/pi/services/actions/tools/labels.py index 56b8226ed8..a0422dec1c 100644 --- a/apps/pi/pi/services/actions/tools/labels.py +++ b/apps/pi/pi/services/actions/tools/labels.py @@ -2,6 +2,8 @@ Labels API tools for Plane labeling operations. """ +from typing import Any +from typing import Dict from typing import Optional from langchain_core.tools import tool @@ -22,8 +24,24 @@ def get_label_tools(method_executor, context): project_id: Optional[str] = None, workspace_slug: Optional[str] = None, description: Optional[str] = None, - ) -> str: - """Create a new label.""" + 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"] @@ -38,18 +56,22 @@ def get_label_tools(method_executor, context): 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_response_with_url(f"Successfully created label '{name}'", result["data"], "label", context) + return await PlaneToolBase.format_success_payload_with_url(f"Successfully created label '{name}'", result["data"], "label", context) else: - return PlaneToolBase.format_error_response("Failed to create label", result["error"]) + 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, - ) -> str: + ) -> 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: @@ -65,16 +87,16 @@ def get_label_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved labels list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved labels list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list labels", result["error"]) + 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, - ) -> str: + ) -> Dict[str, Any]: """Retrieve details of a specific label. Args: @@ -97,9 +119,9 @@ def get_label_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved label details", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved label details", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to retrieve label", result["error"]) + return PlaneToolBase.format_error_payload("Failed to retrieve label", result["error"]) @tool async def labels_update( @@ -109,7 +131,11 @@ def get_label_tools(method_executor, context): name: Optional[str] = None, color: Optional[str] = None, description: Optional[str] = None, - ) -> str: + 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: @@ -117,6 +143,10 @@ def get_label_tools(method_executor, context): 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) """ @@ -127,7 +157,19 @@ def get_label_tools(method_executor, 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}.items() if v is not None} + 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", @@ -139,16 +181,8 @@ def get_label_tools(method_executor, context): ) if result["success"]: - return await PlaneToolBase.format_success_response_with_url(f"Successfully updated label '{label_id}'", result["data"], "label", context) + return await PlaneToolBase.format_success_payload_with_url(f"Successfully updated label '{label_id}'", result["data"], "label", context) else: - return PlaneToolBase.format_error_response("Failed to update label", result["error"]) + return PlaneToolBase.format_error_payload("Failed to update label", result["error"]) - # Get entity search tools relevant only to labels - from .entity_search import get_entity_search_tools - - entity_search_tools = get_entity_search_tools(method_executor, context) - label_entity_search_tools = [ - t for t in entity_search_tools if getattr(t, "name", "").find("label") != -1 or getattr(t, "name", "").find("user") != -1 - ] - - return [labels_create, labels_list, labels_retrieve, labels_update] + label_entity_search_tools + return [labels_create, labels_list, labels_retrieve, labels_update] diff --git a/apps/pi/pi/services/actions/tools/links.py b/apps/pi/pi/services/actions/tools/links.py index 0339a75ae7..61b4df00e8 100644 --- a/apps/pi/pi/services/actions/tools/links.py +++ b/apps/pi/pi/services/actions/tools/links.py @@ -2,6 +2,8 @@ Links API tools for Plane issue link management. """ +from typing import Any +from typing import Dict from typing import Optional from langchain_core.tools import tool @@ -22,15 +24,15 @@ def get_link_tools(method_executor, context): title: Optional[str] = None, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Create a link for an issue. Args: - issue_id: Parameter description (required) - url: Parameter description (required) - title: Parameter description (optional) - project_id: Parameter description (optional) - workspace_slug: Parameter description (optional) + 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: @@ -48,12 +50,12 @@ def get_link_tools(method_executor, context): workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully created link", result["data"]) + return PlaneToolBase.format_success_payload("Successfully created link", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to create link", result["error"]) + 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) -> str: + 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: @@ -63,17 +65,25 @@ def get_link_tools(method_executor, context): 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_response("Successfully retrieved links list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved links list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list links", result["error"]) + 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, - ) -> str: - """Get a single link by ID.""" + ) -> 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"] @@ -84,23 +94,34 @@ def get_link_tools(method_executor, context): "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_response("Successfully retrieved link", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved link", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to retrieve link", result["error"]) + 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, - ) -> str: - """Update link details.""" + ) -> 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"] @@ -118,22 +139,31 @@ def get_link_tools(method_executor, context): "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_response("Successfully updated link", result["data"]) + return PlaneToolBase.format_success_payload("Successfully updated link", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to update link", result["error"]) + 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, - ) -> str: - """Delete a link.""" + ) -> 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"] @@ -144,12 +174,13 @@ def get_link_tools(method_executor, context): "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_response("Successfully deleted link", result["data"]) + return PlaneToolBase.format_success_payload("Successfully deleted link", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to delete link", result["error"]) + return PlaneToolBase.format_error_payload("Failed to delete link", result["error"]) return [links_create, links_list, links_retrieve, links_update, links_delete] diff --git a/apps/pi/pi/services/actions/tools/members.py b/apps/pi/pi/services/actions/tools/members.py index 98fc13e53a..c221e0b632 100644 --- a/apps/pi/pi/services/actions/tools/members.py +++ b/apps/pi/pi/services/actions/tools/members.py @@ -2,6 +2,8 @@ Members API tools for Plane workspace and project member management. """ +from typing import Any +from typing import Dict from typing import Optional from langchain_core.tools import tool @@ -17,7 +19,7 @@ def get_member_tools(method_executor, context): """Get all Members API tools.""" @tool - async def members_get_workspace_members(workspace_slug: Optional[str] = None) -> str: + async def members_get_workspace_members(workspace_slug: Optional[str] = None) -> Dict[str, Any]: """Get all workspace members (excludes bot users). Args: @@ -28,6 +30,7 @@ def get_member_tools(method_executor, context): workspace_slug = context["workspace_slug"] 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"] @@ -43,18 +46,18 @@ def get_member_tools(method_executor, context): or (isinstance(member.get("email"), str) and "_bot@plane.so" in member.get("email", "").lower()) ) ] - return PlaneToolBase.format_success_response("Successfully retrieved workspace members", filtered_members) + 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_response("Successfully retrieved workspace members", members_data) + return PlaneToolBase.format_success_payload("Successfully retrieved workspace members", members_data) else: - return PlaneToolBase.format_error_response("Failed to get workspace members", result["error"]) + return PlaneToolBase.format_error_payload("Failed to get workspace members", result["error"]) @tool async def members_get_project_members( project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Get all project members (excludes bot users). Args: @@ -88,11 +91,11 @@ def get_member_tools(method_executor, context): or (isinstance(member.get("email"), str) and "_bot@plane.so" in member.get("email", "").lower()) ) ] - return PlaneToolBase.format_success_response("Successfully retrieved project members", filtered_members) + 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_response("Successfully retrieved project members", members_data) + return PlaneToolBase.format_success_payload("Successfully retrieved project members", members_data) else: - return PlaneToolBase.format_error_response("Failed to get project members", result["error"]) + return PlaneToolBase.format_error_payload("Failed to get project members", result["error"]) return [members_get_workspace_members, members_get_project_members] diff --git a/apps/pi/pi/services/actions/tools/modules.py b/apps/pi/pi/services/actions/tools/modules.py index ebee0272a3..15d9ddee23 100644 --- a/apps/pi/pi/services/actions/tools/modules.py +++ b/apps/pi/pi/services/actions/tools/modules.py @@ -35,7 +35,7 @@ def get_module_tools(method_executor, context): members: Optional[list] = None, external_id: Optional[str] = None, external_source: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Create a new module. Args: @@ -73,12 +73,12 @@ def get_module_tools(method_executor, context): external_source=external_source, ) if result["success"]: - return await PlaneToolBase.format_success_response_with_url(f"Successfully created module '{name}'", result["data"], "module", context) + return await PlaneToolBase.format_success_payload_with_url(f"Successfully created module '{name}'", result["data"], "module", context) else: - return PlaneToolBase.format_error_response("Failed to create module", result["error"]) + 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) -> str: + async def modules_list(project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """List all modules in a project. Args: @@ -98,19 +98,19 @@ def get_module_tools(method_executor, context): # Validate project_id is present if not project_id: - return PlaneToolBase.format_error_response( + 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_response("Successfully retrieved modules list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved modules list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list modules", result["error"]) + 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) -> str: + 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: @@ -129,12 +129,12 @@ def get_module_tools(method_executor, context): "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_response(f"Successfully added {len(issues)} work items to module", result["data"]) + return PlaneToolBase.format_success_payload(f"Successfully added {len(issues)} work items to module", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to add work items to module", result["error"]) + 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) -> str: + 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: @@ -148,11 +148,11 @@ def get_module_tools(method_executor, context): if project_id is None and "project_id" in context: project_id = context["project_id"] - result = await method_executor.execute("modules", "retrieve", pk=module_id, project_id=project_id, workspace_slug=workspace_slug) + 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_response_with_url("Successfully retrieved module details", result["data"], "module", context) + return await PlaneToolBase.format_success_payload_with_url("Successfully retrieved module details", result["data"], "module", context) else: - return PlaneToolBase.format_error_response("Failed to retrieve module", result["error"]) + return PlaneToolBase.format_error_payload("Failed to retrieve module", result["error"]) @tool async def modules_update( @@ -168,7 +168,7 @@ def get_module_tools(method_executor, context): members: Optional[list] = None, external_id: Optional[str] = None, external_source: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Update module details. Args: @@ -212,15 +212,17 @@ def get_module_tools(method_executor, context): if external_source is not None: update_data["external_source"] = external_source - result = await method_executor.execute("modules", "update", pk=module_id, project_id=project_id, workspace_slug=workspace_slug, **update_data) + 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_response_with_url("Successfully updated module", result["data"], "module", context) + return await PlaneToolBase.format_success_payload_with_url("Successfully updated module", result["data"], "module", context) else: - return PlaneToolBase.format_error_response("Failed to update module", result["error"]) + 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) -> str: + async def modules_archive(module_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """Archive a module. Args: @@ -234,15 +236,15 @@ def get_module_tools(method_executor, context): if project_id is None and "project_id" in context: project_id = context["project_id"] - result = await method_executor.execute("modules", "archive", pk=module_id, project_id=project_id, workspace_slug=workspace_slug) + 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_response("Successfully archived module", result["data"]) + return PlaneToolBase.format_success_payload("Successfully archived module", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to archive module", result["error"]) + 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) -> str: + async def modules_unarchive(module_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """Unarchive a module. Args: @@ -256,15 +258,15 @@ def get_module_tools(method_executor, context): if project_id is None and "project_id" in context: project_id = context["project_id"] - result = await method_executor.execute("modules", "unarchive", pk=module_id, project_id=project_id, workspace_slug=workspace_slug) + 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_response("Successfully unarchived module", result["data"]) + return PlaneToolBase.format_success_payload("Successfully unarchived module", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to unarchive module", result["error"]) + 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) -> str: + 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: @@ -279,12 +281,12 @@ def get_module_tools(method_executor, context): result = await method_executor.execute("modules", "list_archived", project_id=project_id, workspace_slug=workspace_slug) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved archived modules list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved archived modules list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list archived modules", result["error"]) + 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) -> str: + 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: @@ -302,12 +304,14 @@ def get_module_tools(method_executor, context): "modules", "list_work_items", module_id=module_id, project_id=project_id, workspace_slug=workspace_slug ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved module work items", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved module work items", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list module work items", result["error"]) + 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) -> str: + 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: @@ -327,17 +331,9 @@ def get_module_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully removed work item from module", result["data"]) + return PlaneToolBase.format_success_payload("Successfully removed work item from module", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to remove work item from module", result["error"]) - - # Get entity search tools relevant only to modules - from .entity_search import get_entity_search_tools - - entity_search_tools = get_entity_search_tools(method_executor, context) - module_entity_search_tools = [ - t for t in entity_search_tools if getattr(t, "name", "").find("module") != -1 or getattr(t, "name", "").find("user") != -1 - ] + return PlaneToolBase.format_error_payload("Failed to remove work item from module", result["error"]) return [ modules_create, @@ -350,4 +346,4 @@ def get_module_tools(method_executor, context): modules_list_archived, modules_list_work_items, modules_remove_work_item, - ] + module_entity_search_tools + ] diff --git a/apps/pi/pi/services/actions/tools/pages.py b/apps/pi/pi/services/actions/tools/pages.py index 5969b86716..a68fb197e2 100644 --- a/apps/pi/pi/services/actions/tools/pages.py +++ b/apps/pi/pi/services/actions/tools/pages.py @@ -2,6 +2,8 @@ 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 @@ -26,7 +28,7 @@ def get_page_tools(method_executor, context): access: Optional[int] = None, color: Optional[str] = None, logo_props: Optional[dict] = None, - ) -> str: + ) -> Dict[str, Any]: """Create a new page in a project. Args: @@ -71,10 +73,10 @@ def get_page_tools(method_executor, context): # 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_response_with_url(f"Project page '{name}' created successfully", data, "page", context) + 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_response("Failed to create project page", error_msg) + return PlaneToolBase.format_error_payload("Failed to create project page", error_msg) @tool async def pages_create_workspace_page( @@ -83,7 +85,7 @@ def get_page_tools(method_executor, context): access: Optional[int] = None, color: Optional[str] = None, logo_props: Optional[dict] = None, - ) -> str: + ) -> Dict[str, Any]: """Create a new page in the workspace. Args: @@ -117,10 +119,10 @@ def get_page_tools(method_executor, context): if result.get("success"): data = result.get("data", {}) - return await PlaneToolBase.format_success_response_with_url(f"Workspace page '{name}' created successfully", data, "page", context) + 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_response("Failed to create workspace page", error_msg) + 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 @@ -131,7 +133,7 @@ def get_page_tools(method_executor, context): access: Optional[int] = None, color: Optional[str] = None, logo_props: Optional[dict] = None, - ) -> str: + ) -> Dict[str, Any]: """Create a new page either in a project or at the workspace level. Args: @@ -185,34 +187,16 @@ def get_page_tools(method_executor, context): # 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_response_with_url(f"{scope_label} page '{name}' created successfully", data, "page", context) + 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_response(f"Failed to create {scope_label.lower()} page", error_msg) + 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 - # CRITICAL: Include ONLY the entity search tools relevant to Pages - # Keep the tool surface minimal to avoid unintended tool selection - entity_tools = [] - try: - from .entity_search import get_entity_search_tools - - all_entity_tools = get_entity_search_tools(method_executor, context) or [] - allowed_entity_tool_names = { - # Projects-only for page scope selection - "list_member_projects", - "search_project_by_name", - "search_project_by_identifier", - } - entity_tools = [t for t in all_entity_tools if getattr(t, "name", "") in allowed_entity_tool_names] - except Exception: - # Best-effort; if entity search tools are unavailable, proceed with page tools only - pass - if is_project_chat: - return [pages_create_project_page] + entity_tools + return [pages_create_project_page] else: - return [pages_create_page] + entity_tools + return [pages_create_page] diff --git a/apps/pi/pi/services/actions/tools/projects.py b/apps/pi/pi/services/actions/tools/projects.py index 31085af10a..afe972fe47 100644 --- a/apps/pi/pi/services/actions/tools/projects.py +++ b/apps/pi/pi/services/actions/tools/projects.py @@ -2,6 +2,7 @@ Projects API tools for Plane workspace operations. """ +import re from typing import Any from typing import Dict from typing import Optional @@ -9,6 +10,7 @@ from typing import Optional from langchain_core.tools import tool from pi import logger +from pi.core.db import PlaneDBPool from .base import PlaneToolBase @@ -42,7 +44,9 @@ def get_project_tools(method_executor, context): archive_in: Optional[int] = None, close_in: Optional[int] = None, timezone: Optional[str] = None, - ) -> str: + time_tracking_enabled: Optional[bool] = None, + is_issue_type_enabled: Optional[bool] = None, + ) -> Dict[str, Any]: """Create a new project in the workspace. Args: @@ -64,6 +68,8 @@ def get_project_tools(method_executor, context): close_in: Auto-close workitems after N months. Should be less than or equal to 12 (optional) timezone: Timezone for the project (optional) workspace_slug: Workspace slug (provide if known, otherwise auto-detected) + time_tracking_enabled: Enable/disable time tracking, also called worklogs (optional) + is_issue_type_enabled: Enable/disable issue type, also called workitem types (optional) """ # Determine identifier base_identifier = identifier or PlaneToolBase.generate_project_identifier(name) @@ -110,40 +116,73 @@ def get_project_tools(method_executor, context): payload["close_in"] = close_in if timezone is not None: payload["timezone"] = timezone + if time_tracking_enabled is not None: + payload["time_tracking_enabled"] = time_tracking_enabled + if is_issue_type_enabled is not None: + payload["is_issue_type_enabled"] = is_issue_type_enabled # Try to create project result = await method_executor.execute("projects", "create", **payload) if result["success"]: - return await PlaneToolBase.format_success_response_with_url( + return await PlaneToolBase.format_success_payload_with_url( f"Successfully created project '{name}' with identifier '{base_identifier}'", result["data"], "project", context ) else: # If failed due to identifier conflict, try with a different identifier if "already taken" in result.get("error", "") or "409" in result.get("error", ""): # Generate a new identifier with timestamp - new_identifier = PlaneToolBase.generate_fallback_identifier(base_identifier) + new_name, new_identifier = PlaneToolBase.generate_fallback_name_identifier(name, base_identifier) # Retry with new identifier (preserve other fields) + payload["name"] = new_name payload["identifier"] = new_identifier retry_result = await method_executor.execute("projects", "create", **payload) if retry_result["success"]: - return await PlaneToolBase.format_success_response_with_url( - f"Successfully created project '{name}' with identifier '{new_identifier}' (original '{base_identifier}' was taken)", + return await PlaneToolBase.format_success_payload_with_url( + f"Successfully created project '{new_name}' with identifier '{new_identifier}' (original '{name}' was taken)", retry_result["data"], "project", context, ) else: log.info(f"Failed to create project. Error: {retry_result["error"]}\nPayload: {payload}") - return PlaneToolBase.format_error_response("Failed to create project even with alternative identifier", retry_result["error"]) + return PlaneToolBase.format_error_payload("Failed to create project even with alternative identifier", retry_result["error"]) else: + # Check if project was actually created despite the error (e.g. timeout) + try: + # We need to check if the project exists in the DB with the same identifier and workspace_slug + query = """ + SELECT p.id, p.name, p.identifier, p.workspace_id + FROM projects p + JOIN workspaces w ON p.workspace_id = w.id + WHERE p.identifier = $1 AND w.slug = $2 AND p.deleted_at IS NULL + """ + row = await PlaneDBPool.fetchrow(query, (base_identifier, workspace_slug)) + if row: + project_data = { + "id": str(row["id"]), + "name": row["name"], + "identifier": row["identifier"], + "workspace_id": str(row["workspace_id"]), + # Add workspace_slug for context + "workspace_slug": workspace_slug, + } + return await PlaneToolBase.format_success_payload_with_url( + f"Successfully created project '{name}' with identifier '{base_identifier}'", + project_data, + "project", + context, + ) + except Exception as e: + log.error(f"Failed to recover project creation from DB: {e}") + log.info(f"Failed to create project. Error: {result["error"]}\nPayload: {payload}") - return PlaneToolBase.format_error_response("Failed to create project", result["error"]) + return PlaneToolBase.format_error_payload("Failed to create project", result["error"]) @tool - async def projects_list(workspace_slug: Optional[str] = None, per_page: Optional[int] = 20, cursor: Optional[str] = None) -> str: + async def projects_list(workspace_slug: Optional[str] = None, per_page: Optional[int] = 20, cursor: Optional[str] = None) -> Dict[str, Any]: """List projects in the workspace. Args: @@ -158,32 +197,52 @@ def get_project_tools(method_executor, context): result = await method_executor.execute("projects", "list", workspace_slug=workspace_slug, per_page=per_page, cursor=cursor) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved projects list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved projects list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list projects", result["error"]) + return PlaneToolBase.format_error_payload("Failed to list projects", result["error"]) @tool - async def projects_retrieve(pk: str, workspace_slug: Optional[str] = None) -> str: + async def projects_retrieve(project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """Retrieve a single project by ID. Args: - pk: Project ID (required) + project_id: Project ID (required) workspace_slug: Workspace slug (provide if known, otherwise auto-detected) """ + # Lightweight validation to prevent bad calls and noisy 404s + # - Reject placeholders like "" + # - Require a UUID-shaped project_id + try: + if " str: + ) -> Dict[str, Any]: """Update project details. Args: - pk: Project ID (required) + project_id: Project ID (required) workspace_slug: Workspace slug (provide if known, otherwise auto-detected) name: New project name description: New project description @@ -225,6 +286,8 @@ def get_project_tools(method_executor, context): archive_in: Auto-archive issues after N days (optional) close_in: Auto-close issues after N days (optional) timezone: Timezone for the project (optional) + is_time_tracking_enabled: Enable/disable time tracking, also called worklogs (optional) + is_issue_type_enabled: Enable/disable issue type, also called workitem types (optional) """ # Auto-fill workspace_slug from context if not provided if workspace_slug is None and "workspace_slug" in context: @@ -258,6 +321,10 @@ def get_project_tools(method_executor, context): update_data["page_view"] = page_view if intake_view is not None: update_data["intake_view"] = intake_view + if is_time_tracking_enabled is not None: + update_data["is_time_tracking_enabled"] = is_time_tracking_enabled + if is_issue_type_enabled is not None: + update_data["is_issue_type_enabled"] = is_issue_type_enabled if guest_view_all_features is not None: update_data["guest_view_all_features"] = guest_view_all_features if archive_in is not None: @@ -270,18 +337,18 @@ def get_project_tools(method_executor, context): result = await method_executor.execute( "projects", "update", - pk=pk, + project_id=project_id, workspace_slug=workspace_slug, **update_data, ) if result["success"]: - return await PlaneToolBase.format_success_response_with_url("Successfully updated project", result["data"], "project", context) + return await PlaneToolBase.format_success_payload_with_url("Successfully updated project", result["data"], "project", context) else: - return PlaneToolBase.format_error_response("Failed to update project", result["error"]) + return PlaneToolBase.format_error_payload("Failed to update project", result["error"]) @tool - async def projects_archive(project_id: str, workspace_slug: Optional[str] = None) -> str: + async def projects_archive(project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """Archive a project. Args: @@ -295,12 +362,12 @@ def get_project_tools(method_executor, context): result = await method_executor.execute("projects", "archive", project_id=project_id, workspace_slug=workspace_slug) if result["success"]: - return PlaneToolBase.format_success_response("Successfully archived project", result["data"]) + return PlaneToolBase.format_success_payload("Successfully archived project", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to archive project", result["error"]) + return PlaneToolBase.format_error_payload("Failed to archive project", result["error"]) @tool - async def projects_unarchive(project_id: str, workspace_slug: Optional[str] = None) -> str: + async def projects_unarchive(project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: """Restore an archived project. Args: @@ -314,16 +381,15 @@ def get_project_tools(method_executor, context): result = await method_executor.execute("projects", "unarchive", project_id=project_id, workspace_slug=workspace_slug) if result["success"]: - return PlaneToolBase.format_success_response("Successfully unarchived project", result["data"]) + return PlaneToolBase.format_success_payload("Successfully unarchived project", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to unarchive project", result["error"]) + return PlaneToolBase.format_error_payload("Failed to unarchive project", result["error"]) - # Get entity search tools relevant only to projects - from .entity_search import get_entity_search_tools - - entity_search_tools = get_entity_search_tools(method_executor, context) - project_entity_search_tools = [ - t for t in entity_search_tools if getattr(t, "name", "").find("project") != -1 or getattr(t, "name", "").find("user") != -1 + return [ + projects_create, + projects_list, + projects_retrieve, + projects_update, + projects_archive, + projects_unarchive, ] - - return [projects_create, projects_list, projects_retrieve, projects_update, projects_archive, projects_unarchive] + project_entity_search_tools diff --git a/apps/pi/pi/services/actions/tools/properties.py b/apps/pi/pi/services/actions/tools/properties.py index 1260288931..f44a8b9b8b 100644 --- a/apps/pi/pi/services/actions/tools/properties.py +++ b/apps/pi/pi/services/actions/tools/properties.py @@ -2,6 +2,8 @@ Properties API tools for Plane custom property management. """ +from typing import Any +from typing import Dict from typing import Optional from langchain_core.tools import tool @@ -18,20 +20,40 @@ def get_property_tools(method_executor, context): @tool async def properties_create( - name: str, + 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, - ) -> str: - """Create a new property. + ) -> Dict[str, Any]: + """Create a new work item property. Args: - name: Parameter description (required) - type_id: Parameter description (required) - description: Parameter description (optional) - project_id: Parameter description (optional) - workspace_slug: Parameter description (optional) + 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: @@ -42,39 +64,73 @@ def get_property_tools(method_executor, context): result = await method_executor.execute( "properties", "create", - name=name, + 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_response(f"Successfully created property '{name}'", result["data"]) + return PlaneToolBase.format_success_payload(f"Successfully created property '{display_name}'", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to create property", result["error"]) + return PlaneToolBase.format_error_payload("Failed to create property", result["error"]) @tool - async def properties_list(project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> str: - """List all custom properties.""" + 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", project_id=project_id, workspace_slug=workspace_slug) + 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_response("Successfully retrieved properties list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved properties list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list properties", result["error"]) + 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, - ) -> str: - """Get a single property by ID.""" + ) -> 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"] @@ -85,23 +141,54 @@ def get_property_tools(method_executor, context): "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_response("Successfully retrieved property", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved property", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to retrieve property", result["error"]) + return PlaneToolBase.format_error_payload("Failed to retrieve property", result["error"]) @tool async def properties_update( property_id: str, - name: Optional[str] = None, + 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, - ) -> str: - """Update property details.""" + ) -> 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"] @@ -109,32 +196,61 @@ def get_property_tools(method_executor, context): project_id = context["project_id"] # Build update data - update_data = {} - if name is not None: - update_data["name"] = name + 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_response("Successfully updated property", result["data"]) + return PlaneToolBase.format_success_payload("Successfully updated property", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to update property", result["error"]) + 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, - ) -> str: - """Delete a property.""" + ) -> 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"] @@ -145,13 +261,14 @@ def get_property_tools(method_executor, context): "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_response("Successfully deleted property", result["data"]) + return PlaneToolBase.format_success_payload("Successfully deleted property", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to delete property", result["error"]) + return PlaneToolBase.format_error_payload("Failed to delete property", result["error"]) @tool async def properties_create_option( @@ -161,7 +278,7 @@ def get_property_tools(method_executor, context): value: Optional[str] = None, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Create a property option.""" # Auto-fill from context if not provided if workspace_slug is None and "workspace_slug" in context: @@ -180,9 +297,9 @@ def get_property_tools(method_executor, context): workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully created property option", result["data"]) + return PlaneToolBase.format_success_payload("Successfully created property option", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to create property option", result["error"]) + return PlaneToolBase.format_error_payload("Failed to create property option", result["error"]) @tool async def properties_create_value( @@ -192,7 +309,7 @@ def get_property_tools(method_executor, context): value: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Create a property value.""" # Auto-fill from context if not provided if workspace_slug is None and "workspace_slug" in context: @@ -211,9 +328,9 @@ def get_property_tools(method_executor, context): workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully created property value", result["data"]) + return PlaneToolBase.format_success_payload("Successfully created property value", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to create property value", result["error"]) + return PlaneToolBase.format_error_payload("Failed to create property value", result["error"]) @tool async def properties_list_options( @@ -221,7 +338,7 @@ def get_property_tools(method_executor, context): type_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """List property options.""" # Auto-fill from context if not provided if workspace_slug is None and "workspace_slug" in context: @@ -238,9 +355,9 @@ def get_property_tools(method_executor, context): workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved property options", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved property options", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list property options", result["error"]) + return PlaneToolBase.format_error_payload("Failed to list property options", result["error"]) @tool async def properties_list_values( @@ -248,7 +365,7 @@ def get_property_tools(method_executor, context): issue_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """List property values.""" # Auto-fill from context if not provided if workspace_slug is None and "workspace_slug" in context: @@ -265,9 +382,9 @@ def get_property_tools(method_executor, context): workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved property values", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved property values", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list property values", result["error"]) + return PlaneToolBase.format_error_payload("Failed to list property values", result["error"]) @tool async def properties_retrieve_option( @@ -276,7 +393,7 @@ def get_property_tools(method_executor, context): option_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: + ) -> 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: @@ -294,9 +411,9 @@ def get_property_tools(method_executor, context): workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved property option", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved property option", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to retrieve property option", result["error"]) + return PlaneToolBase.format_error_payload("Failed to retrieve property option", result["error"]) @tool async def properties_update_option( @@ -307,7 +424,7 @@ def get_property_tools(method_executor, context): value: Optional[str] = None, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Update property option.""" # Auto-fill from context if not provided if workspace_slug is None and "workspace_slug" in context: @@ -333,9 +450,9 @@ def get_property_tools(method_executor, context): **update_data, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully updated property option", result["data"]) + return PlaneToolBase.format_success_payload("Successfully updated property option", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to update property option", result["error"]) + return PlaneToolBase.format_error_payload("Failed to update property option", result["error"]) @tool async def properties_delete_option( @@ -344,7 +461,7 @@ def get_property_tools(method_executor, context): option_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Delete property option.""" # Auto-fill from context if not provided if workspace_slug is None and "workspace_slug" in context: @@ -362,9 +479,9 @@ def get_property_tools(method_executor, context): workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully deleted property option", result["data"]) + return PlaneToolBase.format_success_payload("Successfully deleted property option", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to delete property option", result["error"]) + return PlaneToolBase.format_error_payload("Failed to delete property option", result["error"]) return [ properties_create, diff --git a/apps/pi/pi/services/actions/tools/states.py b/apps/pi/pi/services/actions/tools/states.py index 78a924a85b..19e083f4c4 100644 --- a/apps/pi/pi/services/actions/tools/states.py +++ b/apps/pi/pi/services/actions/tools/states.py @@ -2,6 +2,8 @@ States API tools for Plane workflow state management. """ +from typing import Any +from typing import Dict from typing import Optional from langchain_core.tools import tool @@ -23,7 +25,12 @@ def get_state_tools(method_executor, context): workspace_slug: Optional[str] = None, description: Optional[str] = None, group: Optional[str] = None, - ) -> str: + 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: @@ -33,6 +40,11 @@ def get_state_tools(method_executor, context): 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: @@ -49,18 +61,23 @@ def get_state_tools(method_executor, context): 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_response_with_url(f"Successfully created state '{name}'", result["data"], "state", context) + return await PlaneToolBase.format_success_payload_with_url(f"Successfully created state '{name}'", result["data"], "state", context) else: - return PlaneToolBase.format_error_response("Failed to create state", result["error"]) + 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, - ) -> str: + ) -> 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: @@ -76,16 +93,16 @@ def get_state_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved states list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved states list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list states", result["error"]) + 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, - ) -> str: + ) -> Dict[str, Any]: """Retrieve details of a specific workflow state. Args: @@ -108,9 +125,9 @@ def get_state_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved state details", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved state details", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to retrieve state", result["error"]) + return PlaneToolBase.format_error_payload("Failed to retrieve state", result["error"]) @tool async def states_update( @@ -120,7 +137,13 @@ def get_state_tools(method_executor, context): name: Optional[str] = None, color: Optional[str] = None, description: Optional[str] = None, - ) -> str: + 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: @@ -128,6 +151,12 @@ def get_state_tools(method_executor, context): 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) """ @@ -138,7 +167,21 @@ def get_state_tools(method_executor, 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}.items() if v is not None} + 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", @@ -150,16 +193,8 @@ def get_state_tools(method_executor, context): ) if result["success"]: - return await PlaneToolBase.format_success_response_with_url(f"Successfully updated state '{state_id}'", result["data"], "state", context) + return await PlaneToolBase.format_success_payload_with_url(f"Successfully updated state '{state_id}'", result["data"], "state", context) else: - return PlaneToolBase.format_error_response("Failed to update state", result["error"]) + return PlaneToolBase.format_error_payload("Failed to update state", result["error"]) - # Get entity search tools relevant only to states - from .entity_search import get_entity_search_tools - - entity_search_tools = get_entity_search_tools(method_executor, context) - state_entity_search_tools = [ - t for t in entity_search_tools if getattr(t, "name", "").find("state") != -1 or getattr(t, "name", "").find("user") != -1 - ] - - return [states_create, states_list, states_retrieve, states_update] + state_entity_search_tools + return [states_create, states_list, states_retrieve, states_update] diff --git a/apps/pi/pi/services/actions/tools/types.py b/apps/pi/pi/services/actions/tools/types.py index d7aaab6e69..5fdc3fa974 100644 --- a/apps/pi/pi/services/actions/tools/types.py +++ b/apps/pi/pi/services/actions/tools/types.py @@ -2,6 +2,8 @@ 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 @@ -20,16 +22,26 @@ def get_type_tools(method_executor, context): 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, - ) -> str: - """Create a new issue type. + ) -> Dict[str, Any]: + """Create a new work item type. Args: - name: Parameter description (required) - description: Parameter description (optional) - project_id: Parameter description (optional) - workspace_slug: Parameter description (optional) + 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: @@ -42,16 +54,21 @@ def get_type_tools(method_executor, context): "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_response(f"Successfully created type '{name}'", result["data"]) + return PlaneToolBase.format_success_payload(f"Successfully created type '{name}'", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to create type", result["error"]) + 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) -> str: + 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: @@ -61,16 +78,16 @@ def get_type_tools(method_executor, context): result = await method_executor.execute("types", "list", project_id=project_id, workspace_slug=workspace_slug) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved types list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved types list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list types", result["error"]) + 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, - ) -> str: + ) -> 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: @@ -86,19 +103,37 @@ def get_type_tools(method_executor, context): workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved type", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved type", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to retrieve type", result["error"]) + 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, - ) -> str: - """Update type details.""" + ) -> 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"] @@ -106,11 +141,21 @@ def get_type_tools(method_executor, context): project_id = context["project_id"] # Build update data - 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", @@ -121,16 +166,16 @@ def get_type_tools(method_executor, context): **update_data, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully updated type", result["data"]) + return PlaneToolBase.format_success_payload("Successfully updated type", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to update type", result["error"]) + 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, - ) -> str: + ) -> Dict[str, Any]: """Delete a type.""" # Auto-fill from context if not provided if workspace_slug is None and "workspace_slug" in context: @@ -146,8 +191,8 @@ def get_type_tools(method_executor, context): workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully deleted type", result["data"]) + return PlaneToolBase.format_success_payload("Successfully deleted type", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to delete type", result["error"]) + return PlaneToolBase.format_error_payload("Failed to delete type", result["error"]) return [types_create, types_list, types_retrieve, types_update, types_delete] diff --git a/apps/pi/pi/services/actions/tools/users.py b/apps/pi/pi/services/actions/tools/users.py index f22117bb13..de6e2dadd2 100644 --- a/apps/pi/pi/services/actions/tools/users.py +++ b/apps/pi/pi/services/actions/tools/users.py @@ -2,6 +2,9 @@ Users API tools for Plane user management operations. """ +from typing import Any +from typing import Dict + from langchain_core.tools import tool from .base import PlaneToolBase @@ -14,18 +17,12 @@ 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() -> str: + 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_response("Successfully retrieved current user", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved current user", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to get current user", result["error"]) + return PlaneToolBase.format_error_payload("Failed to get current user", result["error"]) - # Get entity search tools relevant only to users - from .entity_search import get_entity_search_tools - - entity_search_tools = get_entity_search_tools(method_executor, context) - user_entity_search_tools = [t for t in entity_search_tools if getattr(t, "name", "").find("user") != -1] - - return [users_get_current] + user_entity_search_tools + return [users_get_current] diff --git a/apps/pi/pi/services/actions/tools/workitems.py b/apps/pi/pi/services/actions/tools/workitems.py index 09e8240eb1..6fa2784226 100644 --- a/apps/pi/pi/services/actions/tools/workitems.py +++ b/apps/pi/pi/services/actions/tools/workitems.py @@ -4,6 +4,8 @@ Work Items API tools for Plane issue/task management operations. import logging import uuid +from typing import Any +from typing import Dict from typing import List from typing import Optional @@ -71,6 +73,51 @@ async def resolve_state_to_uuid(state: Optional[str], project_id: Optional[str], return None +async def resolve_type_to_uuid(type_id: Optional[str], project_id: Optional[str], workspace_slug: Optional[str] = None) -> Optional[str]: + """ + Resolve issue type name to UUID with enhanced matching strategy. + + Args: + type_id: Type name or UUID + project_id: Project ID for type resolution + workspace_slug: Optional workspace slug for filtering + + Returns: + Type UUID if resolved, None if type should use project default or if resolution fails + """ + if not type_id: + return None + + # Check if type_id is already a valid UUID + try: + uuid.UUID(type_id) + return type_id + except (ValueError, TypeError): + log.debug(f"Type '{type_id}' is not a UUID, will resolve as name") + + # Only attempt resolution if we have a project_id + if not project_id: + log.warning(f"Cannot resolve type '{type_id}' without project_id") + return None + + try: + from pi.app.api.v1.helpers.plane_sql_queries import search_type_by_name + + type_result = await search_type_by_name(type_id, project_id, workspace_slug) + + if type_result and "id" in type_result: + resolved_uuid = type_result["id"] + log.info(f"Resolved type '{type_id}' to UUID {resolved_uuid}") + return resolved_uuid + else: + log.warning(f"Could not resolve type '{type_id}' to a valid UUID") + return None + + except Exception as e: + log.error(f"Error resolving type '{type_id}': {e}") + return None + + async def get_epic_type_id(method_executor, project_id: str, workspace_slug: str) -> Optional[str]: """Get the epic issue type ID for a project using direct SQL query. @@ -117,33 +164,25 @@ def get_workitem_tools(method_executor, context): target_date: Optional[str] = None, type_id: Optional[str] = None, parent: Optional[str] = None, - module_id: Optional[str] = None, - cycle_id: Optional[str] = None, - estimate_point: Optional[str] = None, - point: Optional[int] = None, external_id: Optional[str] = None, external_source: Optional[str] = None, is_draft: Optional[bool] = None, - ) -> str: + ) -> Dict[str, Any]: """Create a new work item/issue. Args: name: Work item title (required) project_id: Project ID (required - provide from conversation context or previous actions) workspace_slug: Workspace slug (required - provide from conversation context) - description_html: Issue description in HTML format + description_html: Workitem description in HTML format priority: Priority level (high, medium, low, urgent, none) - state: State name or UUID for the issue (e.g., "todo", "in progress", "done") + state: State name or UUID for the workitem (e.g., "todo", "in progress", "done") assignees: List of assignee user IDs labels: List of label IDs start_date: Start date (YYYY-MM-DD format) target_date: Target completion date (YYYY-MM-DD format) - type_id: Issue type ID (optional) + type_id: Workitem type ID (optional). MUST be the 'issue_types_id' (UUID of the issue type definition), NOT the 'project_issue_types_id'. parent: Parent work-item ID (optional) - module_id: Module ID to associate (optional) - cycle_id: Cycle ID to associate (optional) - estimate_point: Estimate point ID (optional) - point: Story points value (optional) external_id: External system identifier (optional) external_source: External system source name (optional) is_draft: Create as draft (optional) @@ -161,6 +200,9 @@ def get_workitem_tools(method_executor, context): # Resolve state name to UUID if needed resolved_state = await resolve_state_to_uuid(state, project_id, workspace_slug) + # Resolve type name to UUID if needed + resolved_type_id = await resolve_type_to_uuid(type_id, project_id, workspace_slug) + result = await method_executor.execute( "workitems", "create", @@ -174,12 +216,8 @@ def get_workitem_tools(method_executor, context): labels=labels, start_date=start_date, target_date=target_date, - type_id=type_id, + type_id=resolved_type_id, parent=parent, - module_id=module_id, - cycle_id=cycle_id, - estimate_point=estimate_point, - point=point, external_id=external_id, external_source=external_source, is_draft=is_draft, @@ -201,9 +239,14 @@ def get_workitem_tools(method_executor, context): except Exception as e: log.warning(f"Could not enrich workitem data with identifier info: {e}") - return await PlaneToolBase.format_success_response_with_url(f"Successfully created work item '{name}'", data, "workitem", context) + return await PlaneToolBase.format_success_payload_with_url( + f"Successfully created work item '{name}'", + data, + "workitem", + context, + ) else: - return PlaneToolBase.format_error_response("Failed to create work item", result["error"]) + return PlaneToolBase.format_error_payload("Failed to create work item", result.get("error")) @tool async def workitems_update( @@ -219,14 +262,10 @@ def get_workitem_tools(method_executor, context): target_date: Optional[str] = None, type_id: Optional[str] = None, parent: Optional[str] = None, - module_id: Optional[str] = None, - cycle_id: Optional[str] = None, - estimate_point: Optional[str] = None, - point: Optional[int] = None, external_id: Optional[str] = None, external_source: Optional[str] = None, is_draft: Optional[bool] = None, - ) -> str: + ) -> Dict[str, Any]: """Update an existing work item/issue. Args: @@ -241,12 +280,8 @@ def get_workitem_tools(method_executor, context): labels: New list of label IDs start_date: New start date (YYYY-MM-DD format) target_date: New target completion date (YYYY-MM-DD format) - type_id: Issue type ID (optional) + type_id: Workitem type ID (optional). MUST be the 'issue_types_id' (UUID of the issue type definition), NOT the 'project_issue_types_id'. parent: Parent work-item ID (optional) - module_id: Module ID to associate (optional) - cycle_id: Cycle ID to associate (optional) - estimate_point: Estimate point ID (optional) - point: Story points value (optional) external_id: External system identifier (optional) external_source: External system source name (optional) is_draft: Mark as draft (optional) @@ -264,6 +299,9 @@ def get_workitem_tools(method_executor, context): # Resolve state name to UUID if needed resolved_state = await resolve_state_to_uuid(state, project_id, workspace_slug) + # Resolve type name to UUID if needed + resolved_type_id = await resolve_type_to_uuid(type_id, project_id, workspace_slug) + # Build update data with only non-None values to avoid overwriting existing fields update_data = { k: v @@ -276,12 +314,8 @@ def get_workitem_tools(method_executor, context): "labels": labels, "start_date": start_date, "target_date": target_date, - "type_id": type_id, + "type_id": resolved_type_id, "parent": parent, - "module_id": module_id, - "cycle_id": cycle_id, - "estimate_point": estimate_point, - "point": point, "external_id": external_id, "external_source": external_source, "is_draft": is_draft, @@ -314,12 +348,12 @@ def get_workitem_tools(method_executor, context): except Exception as e: log.warning(f"Could not enrich workitem update data with identifier info: {e}") - return await PlaneToolBase.format_success_response_with_url("Successfully updated work item", data, "workitem", context) + return await PlaneToolBase.format_success_payload_with_url("Successfully updated work item", data, "workitem", context) else: - return PlaneToolBase.format_error_response("Failed to update work item", result["error"]) + return PlaneToolBase.format_error_payload("Failed to update work item", result["error"]) @tool - async def epics_create( + async def create_epic( name: str, project_id: Optional[str] = None, description_html: Optional[str] = None, @@ -329,13 +363,9 @@ def get_workitem_tools(method_executor, context): labels: Optional[List[str]] = None, start_date: Optional[str] = None, target_date: Optional[str] = None, - parent: Optional[str] = None, - estimate_point: Optional[str] = None, - point: Optional[int] = None, external_id: Optional[str] = None, external_source: Optional[str] = None, - is_draft: Optional[bool] = None, - ) -> str: + ) -> Dict[str, Any]: """Create a new epic work item. Args: @@ -348,12 +378,8 @@ def get_workitem_tools(method_executor, context): labels: List of label IDs start_date: Start date (YYYY-MM-DD format) target_date: Target completion date (YYYY-MM-DD format) - parent: Parent epic ID (optional) - estimate_point: Estimate point ID (optional) - point: Story points value (optional) external_id: External system identifier (optional) external_source: External system source name (optional) - is_draft: Create as draft (optional) Note: This tool automatically sets the work item type to 'epic' for the specified project. To add the created epic to a module or cycle, use modules_add_work_items or cycles_add_work_items after creation. @@ -367,11 +393,11 @@ def get_workitem_tools(method_executor, context): # Get the epic type ID for this project if project_id is None: - return PlaneToolBase.format_error_response("Failed to create epic", "Project ID is required for epic creation. Please specify a project.") + return PlaneToolBase.format_error_payload("Failed to create epic", "Project ID is required for epic creation. Please specify a project.") epic_type_id = await get_epic_type_id(method_executor, project_id, workspace_slug) if not epic_type_id: - return PlaneToolBase.format_error_response( + return PlaneToolBase.format_error_payload( "Failed to create epic", "Could not find epic issue type for this project. Please ensure an epic issue type exists." ) @@ -392,12 +418,8 @@ def get_workitem_tools(method_executor, context): start_date=start_date, target_date=target_date, type_id=epic_type_id, # Automatically set to epic type - parent=parent, - estimate_point=estimate_point, - point=point, external_id=external_id, external_source=external_source, - is_draft=is_draft, ) if result["success"]: @@ -416,12 +438,12 @@ def get_workitem_tools(method_executor, context): except Exception as e: log.warning(f"Could not enrich epic data with identifier info: {e}") - return await PlaneToolBase.format_success_response_with_url(f"Successfully created epic '{name}'", data, "epic", context) + return await PlaneToolBase.format_success_payload_with_url(f"Successfully created epic '{name}'", data, "epic", context) else: - return PlaneToolBase.format_error_response("Failed to create epic", result["error"]) + return PlaneToolBase.format_error_payload("Failed to create epic", result["error"]) @tool - async def epics_update( + async def update_epic( issue_id: str, project_id: Optional[str] = None, name: Optional[str] = None, @@ -432,13 +454,9 @@ def get_workitem_tools(method_executor, context): labels: Optional[List[str]] = None, start_date: Optional[str] = None, target_date: Optional[str] = None, - parent: Optional[str] = None, - estimate_point: Optional[str] = None, - point: Optional[int] = None, external_id: Optional[str] = None, external_source: Optional[str] = None, - is_draft: Optional[bool] = None, - ) -> str: + ) -> Dict[str, Any]: """Update an existing epic work item. Args: @@ -452,12 +470,8 @@ def get_workitem_tools(method_executor, context): labels: List of label IDs start_date: Start date (YYYY-MM-DD format) target_date: Target completion date (YYYY-MM-DD format) - parent: Parent epic ID (optional) - estimate_point: Estimate point ID (optional) - point: Story points value (optional) external_id: External system identifier (optional) external_source: External system source name (optional) - is_draft: Mark as draft (optional) Note: This tool updates an existing epic. It does not change the work item type. To add/move the epic to a module or cycle, use modules_add_work_items or cycles_add_work_items. @@ -485,12 +499,8 @@ def get_workitem_tools(method_executor, context): "labels": labels, "start_date": start_date, "target_date": target_date, - "parent": parent, - "estimate_point": estimate_point, - "point": point, "external_id": external_id, "external_source": external_source, - "is_draft": is_draft, }.items() if v is not None } @@ -505,9 +515,9 @@ def get_workitem_tools(method_executor, context): ) if result["success"]: - return await PlaneToolBase.format_success_response_with_url("Successfully updated epic", result["data"], "epic", context) + return await PlaneToolBase.format_success_payload_with_url("Successfully updated epic", result["data"], "epic", context) else: - return PlaneToolBase.format_error_response("Failed to update epic", result["error"]) + return PlaneToolBase.format_error_payload("Failed to update epic", result["error"]) @tool async def workitems_list( @@ -520,7 +530,22 @@ def get_workitem_tools(method_executor, context): fields: Optional[str] = None, order_by: Optional[str] = None, per_page: Optional[int] = 20, - ) -> str: + priority: Optional[str] = None, + state: Optional[str] = None, + assignees: Optional[List[str]] = None, + labels: Optional[List[str]] = None, + start_date: Optional[str] = None, + target_date: Optional[str] = None, + created_by: Optional[str] = None, + updated_by: Optional[str] = None, + type_id: Optional[str] = None, + parent: Optional[str] = None, + is_draft: Optional[bool] = None, + created_at: Optional[str] = None, + updated_at: Optional[str] = None, + completed_at: Optional[str] = None, + archived_at: Optional[str] = None, + ) -> Dict[str, Any]: """List work items with filtering. Args: @@ -533,6 +558,21 @@ def get_workitem_tools(method_executor, context): fields: Comma-separated list of fields to include in response order_by: Field to order results by. Prefix with '-' for descending order per_page: Number of work items per page (default: 20, max: 100) + priority: Filter by priority (high, medium, low, urgent, none) + state: Filter by state name or UUID + assignees: Filter by assignee user IDs + labels: Filter by label IDs + start_date: Filter by start date + target_date: Filter by target date + created_by: Filter by creator ID + updated_by: Filter by updater ID + type_id: Filter by issue type ID + parent: Filter by parent issue ID + is_draft: Filter by draft status + created_at: Filter by creation time + updated_at: Filter by update time + completed_at: Filter by completion time + archived_at: Filter by archive time """ # Auto-fill from context if not provided if workspace_slug is None and "workspace_slug" in context: @@ -540,6 +580,31 @@ def get_workitem_tools(method_executor, context): if project_id is None and "project_id" in context: project_id = context["project_id"] + # Resolve state name to UUID if needed + resolved_state = await resolve_state_to_uuid(state, project_id, workspace_slug) + + # Build filter arguments + filter_args = { + "priority": priority, + "state": resolved_state, + "assignees": assignees, + "labels": labels, + "start_date": start_date, + "target_date": target_date, + "created_by": created_by, + "updated_by": updated_by, + "type_id": type_id, + "parent": parent, + "is_draft": is_draft, + "created_at": created_at, + "updated_at": updated_at, + "completed_at": completed_at, + "archived_at": archived_at, + } + + # Remove None values + filter_args = {k: v for k, v in filter_args.items() if v is not None} + result = await method_executor.execute( "workitems", "list", @@ -555,13 +620,13 @@ def get_workitem_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved work items list", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved work items list", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list work items", result["error"]) + return PlaneToolBase.format_error_payload("Failed to list work items", result["error"]) @tool async def workitems_retrieve( - pk: str, + issue_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, expand: Optional[str] = None, @@ -569,11 +634,11 @@ def get_workitem_tools(method_executor, context): external_source: Optional[str] = None, fields: Optional[str] = None, order_by: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Retrieve a single work item by ID. Args: - pk: Work item ID (required) + issue_id: Work item ID (required) project_id: Project ID (required - provide from conversation context or previous actions) workspace_slug: Workspace slug (provide if known, otherwise auto-detected) expand: Comma-separated list of related fields to expand in response @@ -591,7 +656,7 @@ def get_workitem_tools(method_executor, context): result = await method_executor.execute( "workitems", "retrieve", - pk=pk, + issue_id=issue_id, project_id=project_id, workspace_slug=workspace_slug, expand=expand, @@ -602,9 +667,9 @@ def get_workitem_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved work item", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved work item", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to retrieve work item", result["error"]) + return PlaneToolBase.format_error_payload("Failed to retrieve work item", result["error"]) @tool async def workitems_search( @@ -613,7 +678,7 @@ def get_workitem_tools(method_executor, context): limit: Optional[int] = None, project_id: Optional[str] = None, workspace_search: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Search work items by criteria. Args: @@ -640,16 +705,16 @@ def get_workitem_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully searched work items", result["data"]) + return PlaneToolBase.format_success_payload("Successfully searched work items", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to search work items", result["error"]) + return PlaneToolBase.format_error_payload("Failed to search work items", result["error"]) @tool async def workitems_get_workspace( issue_identifier: int, project_identifier: str, workspace_slug: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Get work item across workspace using identifiers. Args: @@ -670,9 +735,9 @@ def get_workitem_tools(method_executor, context): ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved workspace work item", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved workspace work item", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to retrieve workspace work item", result["error"]) + return PlaneToolBase.format_error_payload("Failed to retrieve workspace work item", result["error"]) @tool async def workitems_create_relation( @@ -680,7 +745,7 @@ def get_workitem_tools(method_executor, context): relation_type: str, related_issues: List[str], project_id: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Create relationships between work items. Args: @@ -714,13 +779,13 @@ def get_workitem_tools(method_executor, context): # Validate relation type if relation_type not in RELATION_TYPES: valid_types = ", ".join(RELATION_TYPES.keys()) - return PlaneToolBase.format_error_response( + return PlaneToolBase.format_error_payload( "Invalid relation type", f"Relation type '{relation_type}' is not valid. Valid types are: {valid_types}" ) # Validate required parameters if not related_issues: - return PlaneToolBase.format_error_response("Missing related issues", "At least one related issue ID must be provided") + return PlaneToolBase.format_error_payload("Missing related issues", "At least one related issue ID must be provided") result = await method_executor.execute( "workitems", @@ -739,26 +804,54 @@ def get_workitem_tools(method_executor, context): # Create a mock work item response with the source issue_id for entity URL generation source_workitem_data = {"id": issue_id} - return await PlaneToolBase.format_success_response_with_url( + return await PlaneToolBase.format_success_payload_with_url( f"Successfully created {relation_count} '{relation_type}' relation(s) for work item", source_workitem_data, "workitem", context ) else: - return PlaneToolBase.format_error_response("Failed to create work item relation", result["error"]) + return PlaneToolBase.format_error_payload("Failed to create work item relation", result["error"]) - # Get entity search tools relevant only to workitems - from .entity_search import get_entity_search_tools + @tool + async def workitems_delete( + issue_id: str, + project_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Delete a work item/issue. - entity_search_tools = get_entity_search_tools(method_executor, context) - workitem_entity_search_tools = [t for t in entity_search_tools if "workitem" in t.name or "user" in t.name] + Args: + issue_id: Work item ID to delete (required) + project_id: Project ID (required - provide from conversation context or previous actions) + + Warning: This action is permanent and cannot be undone. The work item will be permanently deleted. + """ + # 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( + "workitems", + "delete", + issue_id=issue_id, + project_id=project_id, + workspace_slug=workspace_slug, + ) + + if result["success"]: + return PlaneToolBase.format_success_payload(f"Successfully deleted work item {issue_id}", {"id": issue_id}) + else: + return PlaneToolBase.format_error_payload("Failed to delete work item", result.get("error")) return [ workitems_create, workitems_update, - epics_create, - epics_update, + create_epic, + update_epic, workitems_create_relation, + workitems_delete, workitems_list, # workitems_retrieve, # workitems_search, # workitems_get_workspace - ] + workitem_entity_search_tools + ] diff --git a/apps/pi/pi/services/actions/tools/worklogs.py b/apps/pi/pi/services/actions/tools/worklogs.py index 5bdfb2bda6..1b520d8aa6 100644 --- a/apps/pi/pi/services/actions/tools/worklogs.py +++ b/apps/pi/pi/services/actions/tools/worklogs.py @@ -22,17 +22,21 @@ def get_worklog_tools(method_executor, context): issue_id: str, description: str, duration: int, + created_by: Optional[str] = None, + updated_by: Optional[str] = None, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, - ) -> str: + ) -> Dict[str, Any]: """Create a new worklog entry. Args: - issue_id: Parameter description (required) - description: Parameter description (required) - duration: Parameter description (required) - project_id: Parameter description (optional) - workspace_slug: Parameter description (optional) + issue_id: Issue ID (required) + description: Worklog description (required) + duration: Duration in minutes (required) + 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) + 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: @@ -46,20 +50,22 @@ def get_worklog_tools(method_executor, context): issue_id=issue_id, description=description, duration=duration, + created_by=created_by, + updated_by=updated_by, project_id=project_id, workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully created worklog", result["data"]) + return PlaneToolBase.format_success_payload("Successfully created worklog", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to create worklog", result["error"]) + 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, - ) -> str: + ) -> 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: @@ -75,15 +81,15 @@ def get_worklog_tools(method_executor, context): workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved worklogs", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved worklogs", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to list worklogs", result["error"]) + 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, - ) -> str: + ) -> Dict[str, Any]: """Get project worklog summary.""" # Auto-fill from context if not provided if workspace_slug is None and "workspace_slug" in context: @@ -98,19 +104,33 @@ def get_worklog_tools(method_executor, context): workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully retrieved worklog summary", result["data"]) + return PlaneToolBase.format_success_payload("Successfully retrieved worklog summary", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to get worklog summary", result["error"]) + 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, - ) -> str: - """Update time entry.""" + ) -> 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) + 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"] @@ -123,26 +143,31 @@ def get_worklog_tools(method_executor, context): 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_response("Successfully updated worklog", result["data"]) + return PlaneToolBase.format_success_payload("Successfully updated worklog", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to update worklog", result["error"]) + 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, - ) -> str: + ) -> Dict[str, Any]: """Delete time entry.""" # Auto-fill from context if not provided if workspace_slug is None and "workspace_slug" in context: @@ -158,8 +183,8 @@ def get_worklog_tools(method_executor, context): workspace_slug=workspace_slug, ) if result["success"]: - return PlaneToolBase.format_success_response("Successfully deleted worklog", result["data"]) + return PlaneToolBase.format_success_payload("Successfully deleted worklog", result["data"]) else: - return PlaneToolBase.format_error_response("Failed to delete worklog", result["error"]) + return PlaneToolBase.format_error_payload("Failed to delete worklog", result["error"]) return [worklogs_create, worklogs_list, worklogs_get_summary, worklogs_update, worklogs_delete] diff --git a/apps/pi/pi/services/chat/action_executor.py b/apps/pi/pi/services/chat/action_executor.py new file mode 100644 index 0000000000..f2a976d47c --- /dev/null +++ b/apps/pi/pi/services/chat/action_executor.py @@ -0,0 +1,199 @@ +# batch_execution.py (~150 lines total) +import asyncio +from datetime import datetime +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple + +from sqlmodel.ext.asyncio.session import AsyncSession + +from pi import logger +from pi import settings +from pi.app.api.v1.helpers.plane_sql_queries import get_workspace_slug +from pi.services.actions import MethodExecutor +from pi.services.actions import PlaneActionsExecutor +from pi.services.chat.chat import PlaneChatBot +from pi.services.chat.helpers.action_execution_helpers import format_response +from pi.services.chat.helpers.action_execution_helpers import load_artifacts +from pi.services.chat.helpers.action_execution_helpers import update_flow_steps +from pi.services.chat.helpers.placeholder_orchestrator import PlaceholderOrchestrator + +log = logger.getChild(__name__) + + +async def get_execution_context(request, chatbot: PlaneChatBot, user_id, db) -> Tuple[str, Optional[str], bool]: + """Extract execution context from request and database.""" + + # Token + if request.access_token: + token = request.access_token + log.info(f"Using access_token from request for user {user_id}") + else: + token = await chatbot._get_oauth_token_for_user(db, str(user_id), str(request.workspace_id)) + if token: + log.info(f"Retrieved OAuth token from database for user {user_id}, workspace {request.workspace_id}") + else: + log.warning(f"No valid OAuth token found for user {user_id}, workspace {request.workspace_id}") + + # Project ID from preferences + from pi.services.retrievers.pg_store.chat import get_project_from_chat_preference + + project_id, is_project_chat = await get_project_from_chat_preference(request.chat_id, user_id, db) + + return token, project_id, is_project_chat + + +class BuildModeToolExecutor: + def __init__(self, chatbot: PlaneChatBot, db: AsyncSession): + self.chatbot = chatbot + self.db = db + + async def execute(self, request, user_id): + """Execute the planned actions""" + + log.info(f"EXECUTE ACTION REQUEST: {request}") + # 1. Load artifacts + message_id = request.message_id + chat_id = request.chat_id + planned_actions, original_query, conversation_context = await load_artifacts(request.artifact_data, self.db) + + # 2. Get context + workspace_id = request.workspace_id + workspace_slug = planned_actions[0]["args"].get("workspace_slug", "") + + 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) + + # Validate token exists + if not token: + error_msg = f"No valid OAuth token found for user {user_id} and workspace {request.workspace_id}. Please re-authorize Plane AI." + log.error(error_msg) + raise ValueError(error_msg) + + # Log token type for debugging (without exposing the actual token) + token_type = "API key" if token.startswith("plane_api_") else "OAuth access token" + log.info(f"Using {token_type} for execution (token length: {len(token)}, starts with: {token[:10]}...)") + + # 3. Execution + # Setup + # Create actions executor + if token_type == "API key": + actions_executor = PlaneActionsExecutor(api_key=token, base_url=settings.plane_api.HOST) + else: + actions_executor = PlaneActionsExecutor(access_token=token, base_url=settings.plane_api.HOST) + + method_executor = MethodExecutor(actions_executor) + + context = {"workspace_slug": workspace_slug, "project_id": project_id, "message_id": message_id, "chat_id": chat_id} + + # Execute single action + # If single action, execute directly + if len(planned_actions) == 1: + log.info(f"Single action execution: {planned_actions[0]}") + results = await self.execute_single_action(planned_actions[0], method_executor, context) + + elif self._are_actions_independent(planned_actions): + # Execute independent actions in parallel + log.info(f"Parallel execution for {len(planned_actions)} independent actions") + tasks = [self.execute_single_action(action, method_executor, context) for action in planned_actions] + # Gather results and flatten the list of lists + parallel_results = await asyncio.gather(*tasks) + results = [item for sublist in parallel_results for item in sublist] + + else: + # Orchestrate tool execution + # If more than one action, orchestrate tool execution + results = await self.orchestrate_execution(original_query, planned_actions, conversation_context, method_executor, context) + + # 4. Update DB and return + await update_flow_steps(results, message_id, chat_id, self.db) + + return format_response(planned_actions, results, datetime.utcnow()) + + async def execute_single_action(self, planned_action, method_executor, context): + """Execute a single action.""" + + tool_name = planned_action["tool_name"] + args = planned_action["args"] + entity_type = planned_action["entity_type"] + + # Build tool + all_category_tools = self.chatbot._build_method_tools(entity_type, 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}'") + + # Execute the tool - EXPECT structured payload dict + result = await tool.ainvoke(args) + log.info(f"Tool '{tool_name}' executed with payload: {result}") + if not isinstance(result, dict): + raise ValueError(f"Tool '{tool_name}' must return a structured payload dict, got {type(result)}") + + # Normalize dict payload + message = result.get("message") or "" + ok = bool(result.get("ok", True)) + entity_info = result.get("entity") + + executed = { + "tool_name": tool_name, + "result": message, + "entity_info": entity_info, + "artifact_id": planned_action.get("artifact_id"), + "sequence": 1, + "artifact_type": entity_type, + "executed_at": datetime.utcnow().isoformat(), + "success": ok, + } + return [executed] + + async def orchestrate_execution(self, original_query, planned_actions, conversation_context, method_executor, context): + """ + Execute actions using hybrid orchestration with dependency resolution. + + This method uses PlaceholderOrchestrator to: + - Programmatically track dependencies between actions + - Execute independent actions when ready + - Use focused LLM calls to extract specific values from tool results + - Validate extracted values (e.g., UUID format) + """ + log.info(f"\n\nORCHESTRATE EXECUTION: {len(planned_actions)} actions\n") + + # Use the new hybrid orchestrator + orchestrator = PlaceholderOrchestrator( + planned_actions=planned_actions, + chatbot=self.chatbot, + method_executor=method_executor, + context=context, + chat_id=str(context.get("chat_id")), + message_id=str(context.get("message_id")), + db=self.db, + ) + + # Execute all actions with dependency resolution + results = await orchestrator.execute_all() + + log.info(f"Orchestration complete: {len(results)} actions executed") + return results + + def _has_placeholder(self, value: Any) -> bool: + """Check if a value contains a placeholder recursively.""" + if isinstance(value, str): + return " bool: + """Check if all planned actions are independent (no placeholders).""" + for action in planned_actions: + if self._has_placeholder(action.get("args", {})): + return False + return True diff --git a/apps/pi/pi/services/chat/action_planner.py b/apps/pi/pi/services/chat/action_planner.py new file mode 100644 index 0000000000..6ffedc6042 --- /dev/null +++ b/apps/pi/pi/services/chat/action_planner.py @@ -0,0 +1,872 @@ +"""Action execution logic with retrieval tools.""" + +import contextlib +import json +from collections.abc import AsyncIterator +from typing import Any +from typing import Dict +from typing import Optional +from typing import Sequence +from typing import Union +from typing import cast + +from langchain_core.messages import HumanMessage +from langchain_core.messages import SystemMessage + +from pi import logger +from pi import settings +from pi.app.models.enums import MessageMetaStepType +from pi.core.db.plane_pi.lifecycle import get_streaming_db_session +from pi.services.chat.utils import get_current_timestamp_context +from pi.services.chat.utils import reasoning_dict_maker +from pi.services.retrievers.pg_store.message import upsert_message_flow_steps as _upsert_message_flow_steps +from pi.services.schemas.chat import ActionCategorySelection + +from .helpers.build_mode_helpers import TOOL_NAME_TO_CATEGORY_MAP +from .helpers.build_mode_helpers import auto_resolve_missing_ids +from .helpers.build_mode_helpers import build_advisory_tool_step +from .helpers.build_mode_helpers import build_and_stream_final_response +from .helpers.build_mode_helpers import build_method_executor_and_context +from .helpers.build_mode_helpers import build_planner_summary_step +from .helpers.build_mode_helpers import build_planner_tool_selection_step +from .helpers.build_mode_helpers import build_planning_tools +from .helpers.build_mode_helpers import build_tool_orchestration_context_step +from .helpers.build_mode_helpers import enrich_tool_query_for_display +from .helpers.build_mode_helpers import execute_and_persist_clarification +from .helpers.build_mode_helpers import execute_retrieval_tool_and_build_step +from .helpers.build_mode_helpers import handle_preflight_clarification +from .helpers.build_mode_helpers import persist_skip_category_selection_step +from .helpers.build_mode_helpers import plan_action_and_prepare_outputs +from .helpers.build_mode_helpers import recover_clarification_categories +from .helpers.build_mode_helpers import run_category_router_and_persist +from .helpers.build_mode_helpers import selected_action_categories_display +from .helpers.tool_utils import build_method_prompt +from .helpers.tool_utils import classify_tool +from .helpers.tool_utils import format_tool_message_for_display +from .helpers.tool_utils import format_tool_query_for_display +from .helpers.tool_utils import preflight_missing_required_fields +from .helpers.tool_utils import stream_content_in_chunks +from .helpers.tool_utils import tool_name_shown_to_user + +# from .tool_utils import log_toolset_details + +log = logger.getChild(__name__) + +MAX_ACTION_EXECUTOR_ITERATIONS = settings.chat.MAX_ACTION_EXECUTOR_ITERATIONS + + +async def execute_tools_for_build_mode( + chatbot_instance, + selected_tools, + user_meta, + workspace_id, + workspace_slug, + project_id, + conversation_history, + enhanced_conversation_history, # 🆕 Enhanced context with action details + user_id, + chat_id, + query_flow_store, + combined_tool_query, + query_id, + step_order, + db, + reasoning_container=None, + is_project_chat=None, + pi_sidebar_open=None, + sidebar_open_url=None, +) -> AsyncIterator[Union[str, Dict[str, Any]]]: + """ + Execute action planner with access to retrieval tools + Steps: + 1. Category Selection (Programmatic + LLM Router) + 2. Method Planning & Execution + 3. Tool Execution + 4. Final Response + """ + try: + # Resolve workspace_id from project_id if needed (for project-level chats) + # This must happen BEFORE Phase 1 tools are built + if not workspace_id and project_id: + try: + from pi.app.api.v1.helpers.plane_sql_queries import resolve_workspace_id_from_project_id + + resolved_workspace_id = await resolve_workspace_id_from_project_id(str(project_id)) + if resolved_workspace_id: + workspace_id = str(resolved_workspace_id) + log.info(f"ChatID: {chat_id} - Resolved workspace_id {workspace_id} from project_id {project_id} (Phase 1)") + except Exception as e: + log.error(f"ChatID: {chat_id} - Failed to resolve workspace_id from project_id {project_id}: {e}") + + clarification_requested = False + clarification_payload: dict | None = None + + # Build sdk method caller (executor) early for both Phase 1 (advisory) and Phase 2 (planning) + method_executor, context, workspace_slug = await build_method_executor_and_context( + chatbot_instance=chatbot_instance, + user_id=user_id, + workspace_id=workspace_id, + project_id=project_id, + conversation_history=conversation_history, + user_meta=user_meta, + is_project_chat=is_project_chat, + chat_id=chat_id, + db=db, + ) + # Update project_id from normalized context + project_id = context["project_id"] + + current_step = step_order + tool_flow_steps = [] + + # Check if resuming after clarification and recover categories + selections_list: Sequence[Union[ActionCategorySelection, Dict[str, Optional[str]]]] + skip_category_selection, selections_list, clar_ctx = await recover_clarification_categories( + user_meta=user_meta, + chat_id=chat_id, + db=db, + ) + + if not skip_category_selection: + # ----- PHASE 1: Category Selection (Programmatic + LLM Router) ----- + # Yield early reasoning chunk to provide immediate feedback (similar to ask mode) + stage = "build_beginning" + content = "" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content=content) + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + + ## change point: "πspecial reasoning blockπ: + yield reasoning_chunk_dict + + # Advisory step (direct function call, not tool invocation) + try: + advisory_text, advisory_step, current_step = await build_advisory_tool_step( + combined_tool_query=combined_tool_query, + current_step=current_step, + ) + tool_flow_steps.append(advisory_step) + except Exception as e: + log.warning(f"Category advisory generation failed: {str(e)}") + msg = "An unexpected error occurred. Please try again later." + yield msg + yield f"__FINAL_RESPONSE__{msg}" + return + + # Router and persistence (LLM call) + selections_list, current_step = await run_category_router_and_persist( + chatbot_instance=chatbot_instance, + advisory_text=advisory_text, + combined_tool_query=combined_tool_query, + enhanced_conversation_history=enhanced_conversation_history, + query_id=query_id, + chat_id=chat_id, + current_step=current_step, + db=db, + ) + # Stream category routing decisions as reasoning for frontend visibility + try: + if selections_list: + try: + content = selected_action_categories_display(selections_list) + stage = "selected_action_categories" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content=content) + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + + ## change point: "πspecial reasoning blockπ: + yield reasoning_chunk_dict + + except Exception as e: + log.info(f"ChatID: {chat_id} - Error in reasoning chunk creation in selected_action_categories: {e}", exc_info=True) + pass + + except Exception as e: + log.info(f"ChatID: {chat_id} - Error in advisory step in selected_action_categories: {e}", exc_info=True) + pass + if not selections_list: + log.info(f"ChatID: {chat_id} - Category router returned empty list - unsupported action request") + + else: + # Record that we skipped category selection due to clarification context + # Yield early reasoning chunk for clarification follow-ups + + stage = "actions_clarification_followup" + + content = "" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content=content) + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + + ## change point: "πspecial reasoning blockπ: + yield reasoning_chunk_dict + + current_step = await persist_skip_category_selection_step( + selections_list=cast(Sequence[Dict[str, Optional[str]]], selections_list), + current_step=current_step, + enhanced_conversation_history=enhanced_conversation_history, + query_id=query_id, + chat_id=chat_id, + db=db, + ) + + # ----- PHASE 2: Method Planning & Execution ----- + # method_executor already built above; now build the tools + fresh_retrieval_tools = chatbot_instance._create_tools( + db, + user_meta, + workspace_id, + project_id, + user_id, + chat_id, + query_flow_store, + conversation_history, + query_id, + is_project_chat=is_project_chat, + ) + + combined_tools, all_method_tools, built_categories = build_planning_tools( + chatbot_instance=chatbot_instance, + selections_list=selections_list, + method_executor=method_executor, + context=context, + fresh_retrieval_tools=fresh_retrieval_tools, + ) + if not combined_tools: + log.warning("No method or retrieval tools available for selected categories") + msg = "An unexpected error occurred. Please try again later." + yield msg + yield f"__FINAL_RESPONSE__{msg}" + return + + # Build the planning prompt via shared helper, pass previously derived clar_ctx + method_prompt = build_method_prompt( + combined_tool_query, + project_id, + user_id, + workspace_id, + enhanced_conversation_history, + clarification_context=clar_ctx, + ) + + date_time_context = await get_current_timestamp_context(user_id) + method_prompt = f"{method_prompt}\n\n{date_time_context}" + + # Record the tool orchestration context (enhanced conversation history) before planning + try: + step_ctx, next_step_ctx = build_tool_orchestration_context_step( + current_step=current_step, + enhanced_conversation_history=enhanced_conversation_history, + combined_tool_query=combined_tool_query, + built_categories=built_categories, + all_method_tools=all_method_tools, + combined_tools=combined_tools, + ) + if step_ctx is not None: + tool_flow_steps.append(step_ctx) + current_step = next_step_ctx + except Exception: + pass + + # Log the full method planning prompt and context for debugging + try: + # Determine if this is a clarification follow-up (raw user input) or router-synthesized query + is_clarification_followup = bool(user_meta and isinstance(user_meta, dict) and user_meta.get("clarification_context")) + query_label = "User Intent (clarification response)" if is_clarification_followup else "User Intent" + + # Log comprehensive debugging information + log.info(f"ChatID: {chat_id} - {query_label}: {combined_tool_query}") + log.info(f"ChatID: {chat_id} - Selected Categories: {built_categories}") + log.info(f"ChatID: {chat_id} - Available Tools Count: {len(combined_tools)}") + try: + tool_names_for_log = [t.name for t in combined_tools] + log.info(f"ChatID: {chat_id} - Planning tools: {tool_names_for_log}") + except Exception: + pass + + except Exception as e: + log.warning(f"ChatID: {chat_id} - Failed to log debug info: {e}") + + # Initialize messages for Phase 2 tool orchestration + messages = [SystemMessage(content=method_prompt), HumanMessage(content=combined_tool_query)] + + # Re-bind LLM with the full toolset (action methods + retrieval) + # Some LangChain/OpenAI versions default to no tool calls if not specified. + # deduplicate tools + combined_tools = list({tool.name: tool for tool in combined_tools}.values()) + llm_with_method_tools = chatbot_instance.tool_llm.bind_tools(combined_tools) + # Set tracking context for method planning LLM calls + llm_with_method_tools.set_tracking_context(query_id, db, MessageMetaStepType.ACTION_METHOD_PLANNING, chat_id=str(chat_id)) + + stage = "planner_tool_selection_calling" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content="") + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + + # Continue iterative tool calling with method tools + response = await llm_with_method_tools.ainvoke(messages) + messages.append(response) + # Stream planner's initial reasoning (if present and not a control token) + # Only stream as reasoning if there are tool calls coming (not the final response) + # This represents the LLM's internal thinking/reasoning process + + # Log LLM's initial response for debugging + log.info(f"ChatID: {chat_id} - LLM INITIAL RESPONSE") + try: + _has_tool_calls = hasattr(response, "tool_calls") and bool(getattr(response, "tool_calls", None)) + except Exception: + _has_tool_calls = False + log.info(f"ChatID: {chat_id} - Has Tool Calls: {_has_tool_calls}") + + if _has_tool_calls: + # Yield reasoning chunk for the planner tool selection + try: + _content_preview = str(getattr(response, "content", "") or "").strip() + if _content_preview: + ## change point: "πspecial reasoning blockπ: + stage = "planner_tool_selection" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content=_content_preview) + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + + except Exception: + pass + + # Log planner decisions immediately (before flow step creation) + try: + if _has_tool_calls: + tool_calls_list = getattr(response, "tool_calls", []) or [] + tool_names = [] + for _tc in tool_calls_list: + if isinstance(_tc, dict): + tool_names.append(_tc.get("name", "")) + else: + tool_names.append(getattr(_tc, "name", "")) + + reasoning_text = str(getattr(response, "content", "") or "").strip() + reason_preview = reasoning_text or "(none)" + + log.info(f"ChatID: {chat_id} - Planner selected tools: {tool_names}") + log.info(f"ChatID: {chat_id} - Planner reasoning: {reason_preview}") + else: + # Log when no tool calls are made + reasoning_text = str(getattr(response, "content", "") or "").strip() + log.info(f"ChatID: {chat_id} - Planner selected tools: []") + log.info(f"ChatID: {chat_id} - Planner reasoning (no tools): {reasoning_text}") + except Exception as e: + log.warning(f"ChatID: {chat_id} - Failed to log planner decisions: {e}") + + # Record planner decision for this response (selected tools + reasoning) + try: + selection_step, next_step_sel = build_planner_tool_selection_step(response, current_step=current_step) + if selection_step is not None: + tool_flow_steps.append(selection_step) + current_step = next_step_sel + except Exception: + pass + + # Handle iterative method tool calling until completion + planned_actions: list = [] + max_iterations = MAX_ACTION_EXECUTOR_ITERATIONS # Safety limit to prevent infinite loops + iteration_count = 0 + loop_warning_detected = False + + # Continue the loop until either: + # 1. No more tool calls are returned by the LLM, OR + # 2. At least one action has been planned (to handle simple requests) + # 3. Maximum iterations reached (safety check) + loop_condition = (_has_tool_calls or len(planned_actions) == 0) and iteration_count < max_iterations + + # Track tool calls to detect loops + tool_call_history = [] + + while loop_condition: + iteration_count += 1 + tool_messages = [] + + # If no tool calls in this response, the LLM has finished its work + # Either it has planned actions, or it's providing a final answer (retrieval-only) + if not _has_tool_calls: + log.info(f"ChatID: {chat_id} - No tool calls returned; LLM has finished (planned {len(planned_actions)} actions)") + break + + # Execute all tool calls in this round + log.info(f"ChatID: {chat_id} - Entering the for loop to execute selected tool calls in this iteration: {iteration_count}") + for tool_call in getattr(response, "tool_calls", []): + # Handle both dictionary and object access patterns for LangChain tool calls + if isinstance(tool_call, dict): + tool_name = tool_call.get("name", "") + tool_args = tool_call.get("args", {}) + tool_id = tool_call.get("id", "") + else: + # Handle object access pattern + tool_name = getattr(tool_call, "name", "") + tool_args = getattr(tool_call, "args", {}) + tool_id = getattr(tool_call, "id", "") + + # Additional validation: ensure we have a valid tool name + if not tool_name or not isinstance(tool_name, str): + log.warning(f"Invalid tool name: {tool_name}, skipping tool call") + continue + + # Log each tool call execution for debugging + log.info(f"ChatID: {chat_id} - EXECUTING TOOL: {tool_name} with args: {tool_args}") + + # Track tool calls to detect loops + tool_call_signature = f"{tool_name}({tool_args})" + tool_call_history.append(tool_call_signature) + + # Check for repeated identical calls (potential loop) + if tool_call_history.count(tool_call_signature) > 2: + log.warning( + f"ChatID: {chat_id} - DETECTED LOOP: Tool {tool_name} called {tool_call_history.count(tool_call_signature)} times with same args: {tool_args}" # noqa: E501 + ) # noqa: E501 + log.warning(f"ChatID: {chat_id} - Tool call history: {tool_call_history[-10:]}") # Last 10 calls + loop_warning_detected = True + + # Special handling for clarification tool + elif tool_name == "ask_for_clarification": + flow_step, tool_message, next_step, stream_chunk = await execute_and_persist_clarification( + tool_args=tool_args, + tool_id=tool_id, + combined_tools=combined_tools, + combined_tool_query=combined_tool_query, + current_step=current_step, + built_categories=list(built_categories) if "built_categories" in locals() else [], + all_method_tools=list(all_method_tools) if "all_method_tools" in locals() else [], + chat_id=chat_id, + query_id=query_id, + db=db, + ) + if tool_message is not None: + tool_messages.append(tool_message) + if flow_step is not None: + tool_flow_steps.append(flow_step) + current_step = next_step + yield stream_chunk + + clarification_requested = True + response.tool_calls = [] if hasattr(response, "tool_calls") else None + break + + # Check if this is an action tool (not a retrieval tool) + # + # Tool Classification Logic: + # 1. Standard retrieval tools: vector_search_tool, structured_db_tool, etc. + # 2. Read-only operations: tools with patterns like _list, _retrieve, _get, _search + # 3. Modifying operations: tools with patterns like _create, _update, _delete, _add, _remove, _archive, _unarchive + # 4. Safety rule: if a tool has both read-only and modifying patterns, treat as modifying (safer) + # + # This ensures that operations like 'list_modules' don't require user approval, + # while operations like 'create_workitem' still do. + + # Classify tool via shared helper + _is_retrieval_tool, is_action_tool = classify_tool(tool_name) + + if is_action_tool: + # Build context for entity resolution + action_context = { + "workspace_slug": workspace_slug, + "project_id": project_id, + "workspace_id": workspace_id, + } + + # Preflight: ensure required fields exist before planning actions + try: + missing_required = preflight_missing_required_fields(tool_name, tool_args, action_context) + except Exception: + missing_required = [] + + # Attempt auto-resolution before asking user + did_resolve = await auto_resolve_missing_ids( + tool_name=tool_name, + tool_args=tool_args, + missing_required=missing_required, + combined_tools=combined_tools, + workspace_slug=workspace_slug, + ) + if did_resolve: + with contextlib.suppress(Exception): + missing_required = preflight_missing_required_fields(tool_name, tool_args, action_context) + + if missing_required: + clar_req, clar_payload, clar_tool_msg, clar_flow_step, next_step = await handle_preflight_clarification( + tool_name=tool_name, + tool_args=tool_args, + action_context=action_context, + missing_required=missing_required, + method_executor=method_executor, + workspace_slug=workspace_slug, + chat_id=chat_id, + tool_id=tool_id, + current_step=current_step, + combined_tool_query=combined_tool_query, + is_project_chat=is_project_chat, + built_categories=list(built_categories) if "built_categories" in locals() else [], + all_method_tools=list(all_method_tools) if "all_method_tools" in locals() else [], + combined_tools=combined_tools, + query_id=query_id, + db=db, + ) + if clar_req: + if clar_tool_msg is not None: + tool_messages.append(clar_tool_msg) + if clar_flow_step is not None: + tool_flow_steps.append(clar_flow_step) + current_step = next_step + clarification_requested = True + try: + yield f"πspecial clarification blockπ: {json.dumps(clar_payload)}\n" + except Exception: + yield f"πspecial clarification blockπ: {str(clar_payload)}\n" + response.tool_calls = [] if hasattr(response, "tool_calls") else None + break + + stage = "planner_tool_selection_final" + user_friendly_tool_name = TOOL_NAME_TO_CATEGORY_MAP.get(tool_name, {}).get("front_facing_name", tool_name) + if "name" in tool_args or "title" in tool_args: + tq = f"{tool_args.get("name", tool_args.get("title", ""))}" + else: + tq = "" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name=user_friendly_tool_name, tool_query=tq, content="") + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + + yield reasoning_chunk_dict + + planned_step, action_summary, tool_message_ack, next_step = await plan_action_and_prepare_outputs( + tool_name=tool_name, + tool_args=tool_args, + tool_id=tool_id, + current_step=current_step, + chat_id=chat_id, + query_id=query_id, + conversation_history=conversation_history, + combined_tool_query=combined_tool_query, + db=db, + project_id=project_id, + workspace_slug=workspace_slug, + ) + # log planned step + yield f"πspecial actions blockπ: {json.dumps(action_summary, default=str)}" + # Record the planned action and tool execution result + if tool_message_ack is not None: + tool_messages.append(tool_message_ack) + if planned_step is not None: + tool_flow_steps.append(planned_step) + current_step = next_step + + planned_actions.append(action_summary) + + else: + # Format and enrich tool query for display before execution + tool_query = format_tool_query_for_display(tool_name, tool_args, combined_tool_query) + tool_query = await enrich_tool_query_for_display(tool_name, tool_args, tool_query) + + # Yield execution reasoning block with enhanced query before execution + ## change point: "πspecial reasoning blockπ: + stage = "retrieval_tool_execution" + user_friendly_tool_name = tool_name_shown_to_user(tool_name) + content = "" + reasoning_chunk_dict = reasoning_dict_maker( + stage=stage, tool_name=user_friendly_tool_name, tool_query=tool_query, content=content + ) + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + + # For non-action tools (retrieval), execute via helper + result = await execute_retrieval_tool_and_build_step( + tool_name=tool_name, + tool_id=tool_id, + tool_args=tool_args, + combined_tool_query=combined_tool_query, + combined_tools=combined_tools, + query_flow_store=query_flow_store, + current_step=current_step, + tool_query=tool_query, + ) + ( + flow_step, + tool_message, + next_step_exec, + tool_query, + execution_success, + execution_error, + ) = result # type: ignore[assignment] + log.info(f"ChatID: {chat_id} - Tool {tool_name} result: {tool_message.content}") + if tool_message is not None: + tool_messages.append(tool_message) + # Format tool message content for user-friendly display (remove UUIDs, URLs, etc.) + ## change point: "πspecial reasoning blockπ: + stage = "retrieval_tool_execution_message" + cleaned_content = format_tool_message_for_display(tool_message.content) + user_friendly_tool_name = tool_name_shown_to_user(tool_name) + content = f"{user_friendly_tool_name}'s result: {cleaned_content}\n\n" + reasoning_chunk_dict = reasoning_dict_maker( + stage=stage, tool_name=user_friendly_tool_name, tool_query=tool_query, content=content + ) + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + else: + tool_messages.append(tool_message) + if flow_step is not None: + tool_flow_steps.append(flow_step) + current_step = next_step_exec + + # If clarification was requested, stop further planning + if clarification_requested: + break + + # Add tool results to conversation and continue + messages.extend(tool_messages) + + # Get next response from LLM + response = await llm_with_method_tools.ainvoke(messages) + + messages.append(response) + # Stream planner reasoning for this iteration (if present and not a control token) + # Only stream as reasoning if there are more tool calls coming (not the final response) + # This represents the LLM's internal thinking/reasoning process + has_more_tool_calls = bool(getattr(response, "tool_calls", None)) + if has_more_tool_calls: + try: + _iter_content = str(getattr(response, "content", "") or "").strip() + if _iter_content: + ## change point: "πspecial reasoning blockπ: + stage = "planner_tool_selection" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content=_iter_content) + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + except Exception: + pass + + # update _has_tool_calls + _has_tool_calls = has_more_tool_calls + + # Log planner decisions for this iteration + try: + if _has_tool_calls: + tool_calls_list = getattr(response, "tool_calls", []) or [] + tool_names = [] + for _tc in tool_calls_list: + if isinstance(_tc, dict): + tool_names.append(_tc.get("name", "")) + else: + tool_names.append(getattr(_tc, "name", "")) + + reasoning_text = str(getattr(response, "content", "") or "").strip() + reason_preview = reasoning_text or "(none)" + + log.info(f"ChatID: {chat_id} - Planner selected tools (iteration {iteration_count}): {tool_names}") + log.info(f"ChatID: {chat_id} - Planner reasoning (iteration {iteration_count}): {reason_preview}") + else: + # Log when no tool calls are made in iteration + reasoning_text = str(getattr(response, "content", "") or "").strip() + log.info(f"ChatID: {chat_id} - Planner selected tools (iteration {iteration_count}): []") + log.info(f"ChatID: {chat_id} - Planner reasoning (iteration {iteration_count}, no tools): {reasoning_text}") + except Exception as e: + log.warning(f"ChatID: {chat_id} - Failed to log planner decisions for iteration {iteration_count}: {e}") + + # Record planner decision for this iteration (selected tools + reasoning) + try: + selection_step, next_step_sel = build_planner_tool_selection_step(response, current_step=current_step) + if selection_step is not None: + try: + ed = selection_step.setdefault("execution_data", {}) + ed["iteration"] = iteration_count + except Exception: + pass + tool_flow_steps.append(selection_step) + current_step = next_step_sel + except Exception: + pass + + # Update loop condition for next iteration + loop_condition = (_has_tool_calls or len(planned_actions) == 0) and iteration_count < max_iterations + + # Log why the tool selection loop exited + if clarification_requested: + # Persist any steps recorded so far (including clarification) + if tool_flow_steps: + with contextlib.suppress(Exception): + async with get_streaming_db_session() as _subdb: + await _upsert_message_flow_steps( + message_id=query_id, + chat_id=chat_id, + flow_steps=tool_flow_steps, + db=_subdb, + ) + # End stream without free-form content + yield "__FINAL_RESPONSE__" + return + elif iteration_count >= max_iterations: + log.warning(f"Tool selection loop exited due to maximum iterations ({max_iterations}) reached") + if len(planned_actions) == 0: + # Signal failure and stop before emitting any free-form LLM content + msg = "An unexpected error occurred. Please try again later." + yield msg + # Persist any retrieval steps captured so far + # Append planning summary before persisting steps + try: + step, next_step_ps = build_planner_summary_step( + current_step=current_step, + iteration_count=iteration_count, + planned_actions_count=len(planned_actions), + loop_warning_detected=loop_warning_detected, + tool_calls_count=len(tool_call_history), + ) + tool_flow_steps.append(step) + current_step = next_step_ps + except Exception: + pass + if tool_flow_steps: + with contextlib.suppress(Exception): + async with get_streaming_db_session() as _subdb: + await _upsert_message_flow_steps( + message_id=query_id, + chat_id=chat_id, + flow_steps=tool_flow_steps, + db=_subdb, + ) + # Do not include free-form content; end stream + yield f"__FINAL_RESPONSE__{msg}" + return + elif len(planned_actions) == 0: + # No actions planned - this is a retrieval-only query or unsupported feature request + # The LLM should have provided an answer/explanation in its content + log.info(f"ChatID: {chat_id} - No actions planned; streaming LLM's response as final answer") + + # Stream the LLM's content as the final answer + if hasattr(response, "content") and response.content: + content = str(response.content).strip() + if content: + # Stream the content in chunks for better UX + async for stream_chunk in stream_content_in_chunks(content): + yield stream_chunk + yield "\n" + else: + # Fallback if no content provided + fallback_msg = "I wasn't able to find a suitable response. Could you please rephrase your request?" + yield fallback_msg + yield "\n" + content = fallback_msg + else: + # Fallback if no content attribute + fallback_msg = "I wasn't able to find a suitable response. Could you please rephrase your request?" + yield fallback_msg + yield "\n" + content = fallback_msg + + # Record the flow steps + try: + step, next_step_ps = build_planner_summary_step( + current_step=current_step, + iteration_count=iteration_count, + planned_actions_count=0, + loop_warning_detected=loop_warning_detected, + tool_calls_count=len(tool_call_history), + ) + tool_flow_steps.append(step) + current_step = next_step_ps + except Exception: + pass + + if tool_flow_steps: + with contextlib.suppress(Exception): + async with get_streaming_db_session() as _subdb: + await _upsert_message_flow_steps( + message_id=query_id, + chat_id=chat_id, + flow_steps=tool_flow_steps, + db=_subdb, + ) + + yield f"__FINAL_RESPONSE__{content}" + return + else: + log.debug(f"Tool selection loop completed successfully after {iteration_count} iterations with {len(planned_actions)} planned actions") + + # Build final response and optional planner_no_actions step + final_response_chunks, current_step, no_actions_step = await build_and_stream_final_response( + response=response, + current_step=current_step, + iteration_count=iteration_count, + chat_id=chat_id, + ) + if no_actions_step is not None: + tool_flow_steps.append(no_actions_step) + + # Stream final response chunks - stream actual content + for chunk in final_response_chunks: + # Check if this is the actual response content + if hasattr(response, "content") and response.content: + content = str(response.content).strip() + # Stream actual content + if content: + chunk_without_newline = chunk.rstrip("\n") + # Check if this chunk matches the actual content (it should be content + "\n") + if chunk_without_newline == content: + # This is the actual response content - stream it in chunks + async for stream_chunk in stream_content_in_chunks(chunk_without_newline): + yield stream_chunk + # Add newline if it was in the original chunk + if chunk.endswith("\n"): + yield "\n" + continue + + # Other message types - yield directly + yield chunk + + # Record tool executions in database + # Append planning summary before persisting steps + try: + step, next_step_ps = build_planner_summary_step( + current_step=current_step, + iteration_count=iteration_count, + planned_actions_count=len(planned_actions), + loop_warning_detected=loop_warning_detected, + tool_calls_count=len(tool_call_history), + ) + tool_flow_steps.append(step) + current_step = next_step_ps + except Exception: + pass + if tool_flow_steps: + async with get_streaming_db_session() as _subdb: + flow_step_result = await _upsert_message_flow_steps( + message_id=query_id, + chat_id=chat_id, + flow_steps=tool_flow_steps, + db=_subdb, + ) + + if flow_step_result["message"] != "success": + log.warning("Failed to record action execution in database") + + # Return the complete response for storage in chat history + if final_response_chunks: + # Yield a special signal that can be detected by the calling function + # This follows the same pattern as response_processor.py + final_response = "".join(final_response_chunks) + yield f"__FINAL_RESPONSE__{final_response}" + else: + # Signal end without free-form content + yield "__FINAL_RESPONSE__" + + except Exception: + # Log full traceback for diagnosis + log.error(f"ChatID: {chat_id} - Error in action execution", exc_info=True) + # Emit user-friendly error and final response so caller can persist + error_msg = "An unexpected error occurred. Please try again later." + try: + yield error_msg + yield f"__FINAL_RESPONSE__{error_msg}" + except Exception: + # Best effort; if streaming breaks here, nothing else we can do + pass + return diff --git a/apps/pi/pi/services/chat/askmode_tool_executor.py b/apps/pi/pi/services/chat/askmode_tool_executor.py new file mode 100644 index 0000000000..2b8586afaf --- /dev/null +++ b/apps/pi/pi/services/chat/askmode_tool_executor.py @@ -0,0 +1,459 @@ +"""Retrieval tool execution logic for single and multi-tool scenarios. +Also includes tools for ask mode.""" + +import json +from collections.abc import AsyncIterator +from typing import Any +from typing import Dict +from typing import List +from typing import Tuple +from typing import Union + +from langchain_core.messages import BaseMessage +from langchain_core.messages import HumanMessage +from langchain_core.messages import SystemMessage +from langchain_core.messages import ToolMessage + +from pi import logger +from pi.app.models.enums import MessageMetaStepType +from pi.services.chat.helpers import ask_mode_helpers +from pi.services.chat.helpers.flow_tracking import check_and_build_clarification +from pi.services.chat.helpers.flow_tracking import execute_tool_step +from pi.services.chat.helpers.flow_tracking import orchestrate_llm_step +from pi.services.chat.helpers.flow_tracking import store_and_format_clarification +from pi.services.chat.helpers.tool_utils import format_tool_message_for_display +from pi.services.chat.helpers.tool_utils import format_tool_query_for_display +from pi.services.chat.helpers.tool_utils import stream_content_in_chunks +from pi.services.chat.helpers.tool_utils import tool_name_shown_to_user +from pi.services.chat.prompts import pai_ask_system_prompt +from pi.services.chat.utils import reasoning_dict_maker + +log = logger.getChild(__name__) + + +def extract_facts_from_tool_result(tool_name: str, result: Any) -> Dict[str, Any]: + """Extract structured facts from tool results for enhanced history.""" + facts = {} + + try: + # Handle StandardAgentResponse dict format (most common case) + # Tool handlers return dicts like: {"results": "...", "entity_urls": [...], ...} + if isinstance(result, dict): + # Extract entity URLs (critical for citations and linking) + entity_urls = result.get("entity_urls") + if entity_urls and isinstance(entity_urls, list): + facts["entity_urls"] = entity_urls[:10] # Limit to prevent bloat + facts["entity_urls_count"] = len(entity_urls) + # Extract IDs from entity URLs for easier reference + entity_ids = [url.get("id") for url in entity_urls if isinstance(url, dict) and url.get("id")] + if entity_ids: + facts["entity_ids"] = entity_ids[:10] + + # Extract intermediate results (SQL debugging for structured_db_tool) + intermediate_results = result.get("intermediate_results") + if intermediate_results and isinstance(intermediate_results, dict): + if intermediate_results.get("generated_sql"): + facts["sql_query"] = str(intermediate_results["generated_sql"])[:300] + if intermediate_results.get("relevant_tables"): + facts["relevant_tables"] = intermediate_results["relevant_tables"] + if intermediate_results.get("final_query"): + facts["final_query"] = str(intermediate_results["final_query"])[:200] + + # Extract search metadata (for vector/semantic search tools) + execution_metadata = result.get("execution_metadata") + if execution_metadata and isinstance(execution_metadata, dict): + facts["search_metadata"] = execution_metadata + + # Extract result preview from "results" field + results_text = result.get("results") + if results_text: + facts["result_preview"] = str(results_text)[:300] + + # Extract other common fields that might be in the dict + for key in ["entity_id", "entity_name", "entity_url", "count", "ids", "results_count"]: + if key in result and result[key]: + facts[key] = result[key] + + # Handle string results (simple tool responses) + elif isinstance(result, str): + facts["result_preview"] = result[:300] + + except Exception as e: + log.debug(f"Could not extract facts from {tool_name}: {e}") + + return facts + + +async def execute_tools_for_ask_mode( + chatbot_instance, + user_meta, + workspace_id, + workspace_slug, + project_id, + conversation_history, + enhanced_conversation_history, # 🆕 Enhanced context with action details + user_id, + chat_id, + query_flow_store, + enhanced_query_for_processing, # This is what's actually passed from chat.py + query_id, + step_order, + db, + parsed_query, + reasoning_container=None, +) -> AsyncIterator[Union[str, Dict[str, Any]]]: + """Execute tools for ask mode""" + log.info(f"ChatID: {chat_id} - Executing tools for ask mode with enhanced_conversation_history: {enhanced_conversation_history}") + try: + # Extract workspace_in_context from query_flow_store + workspace_in_context = True + if isinstance(query_flow_store, dict): + workspace_in_context = query_flow_store.get("workspace_in_context", True) + + log.info(f"ChatID: {chat_id} - Workspace in context: {workspace_in_context}") + + # Resolve workspace_id and workspace_slug (only when workspace is in context) + workspace_id, workspace_slug = await ask_mode_helpers.resolve_workspace_id_and_slug( + workspace_id=workspace_id, + project_id=project_id, + chat_id=chat_id, + ) + + tools = await chatbot_instance._create_tools_for_ask_mode( + db, + user_meta, + workspace_id, + workspace_slug, + project_id, + user_id, + chat_id, + query_flow_store, + conversation_history, + query_id, + workspace_in_context=workspace_in_context, + chatbot_instance=chatbot_instance, + ) + + if not tools: + log.warning("Unable to initialize tools. Please try again.") + msg = "An unexpected error occurred. Please try again later." + yield msg + return + + try: + # Determine if this is a clarification follow-up (raw user input) + is_clarification_followup = bool(user_meta and isinstance(user_meta, dict) and user_meta.get("clarification_context")) + query_label = "Query (raw user input from clarification)" if is_clarification_followup else "Query" + + # Log comprehensive debugging information + log.info(f"ChatID: {chat_id} - {query_label}: {enhanced_query_for_processing}") + log.info(f"ChatID: {chat_id} - Ask mode LLM input - Available tools: {[t.name for t in tools]}") + + except Exception as e: + log.warning(f"ChatID: {chat_id} - Failed to log debug info: {e}") + + # Build enhanced prompt and context block + custom_prompt, context_block = await ask_mode_helpers.construct_enhanced_prompt_and_context( + enhanced_conversation_history=enhanced_conversation_history, + project_id=project_id, + workspace_id=workspace_id, + user_id=user_id, + user_meta=user_meta, + workspace_in_context=workspace_in_context, + ) + + # Bind tools to the LLM - explicitly set tool_choice to 'auto' to ensure tool use is enabled + # deduplicate tools + tools = list({tool.name: tool for tool in tools}.values()) + llm_with_tools = chatbot_instance.tool_llm.bind_tools(tools) + + # Set tracking context on the bound LLM instance + llm_with_tools.set_tracking_context(query_id, db, MessageMetaStepType.TOOL_ORCHESTRATION, chat_id=str(chat_id)) + + # Track execution - use FlowStepCollector context manager for automatic persistence + current_step = step_order + messages: List[BaseMessage] = [] # type: ignore[no-redef] + responses: List[Tuple[str, str, Union[str, Dict[str, Any]]]] = [] + + system_prompt_to_use = f"{pai_ask_system_prompt}\n\n{context_block}" + messages.append(SystemMessage(content=system_prompt_to_use)) + + query_to_use = f"{custom_prompt}\n\nUser Query: {enhanced_query_for_processing}" + messages.append(HumanMessage(content=query_to_use)) + + # Log what's sent to the tool selection LLM + log.info(f"ChatID: {chat_id} - Ask mode LLM input - Query: {query_to_use}") + + ## change point: "πspecial reasoning blockπ: + # Yield initial status - different message for clarification follow-ups + if is_clarification_followup: + stage = "ask_mode_clarification_followup" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content="") + else: + stage = "ask_mode_beginning" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content="") + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + + # Initial invocation of LLM with tools (decorator persists selection/context/reasoning) + ai_message, current_step = await orchestrate_llm_step( + llm_with_tools, + messages, + query_id=query_id, + chat_id=chat_id, + db=db, + current_step=current_step, + include_context=True, + enhanced_conversation_history=enhanced_conversation_history, + enhanced_query_for_processing=enhanced_query_for_processing, + tools=tools, + ) + + # Log LLM's initial response for debugging + log.info(f"ChatID: {chat_id} - Has Tool Calls: {hasattr(ai_message, "tool_calls") and bool(getattr(ai_message, "tool_calls", None))}") + + # Check if LLM made any tool calls + if not ai_message.tool_calls: + log.info(f"ChatID: {chat_id} - No tool calls found in the initial LLM response.") + stage = "final_response" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content="") + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + + if ai_message.content: + # Stream the final answer in chunks to simulate streaming + async for chunk in stream_content_in_chunks(str(ai_message.content)): + yield chunk + return + + # Stream LLM's initial reasoning content (internal thinking) + # Only stream as reasoning if there ARE tool calls (otherwise it's the final answer) + try: + if hasattr(ai_message, "content") and ai_message.content: + reasoning_content = str(ai_message.content).strip() + if reasoning_content: + stage = "planner_tool_selection" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content=reasoning_content) + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + except Exception: + pass + + # Add the AIMessage with tool_calls to conversation BEFORE processing tool calls + messages.append(ai_message) + + # Process tool calls iteratively; persist steps via decorators + while ai_message.tool_calls: + # Execute all tool calls + tool_messages: List[ToolMessage] = [] + for tool_call in ai_message.tool_calls: + tool_name = tool_call["name"] + tool_args = tool_call["args"] + tool_id = tool_call["id"] + + # Intercept clarification requests and short-circuit + if tool_name == "ask_for_clarification": + log.info(f"ChatID: {chat_id} - Clarification requested by LLM: {tool_args.get("reason", "No reason provided")}") + clarification_payload, result = await check_and_build_clarification(tools=tools, tool_name=tool_name, tool_args=tool_args) + formatted_text, current_step = await store_and_format_clarification( + query_id=query_id, + chat_id=chat_id, + db=db, + current_step=current_step, + tool_name=tool_name, + tool_args=tool_args, + result=result, + clarification_payload=clarification_payload, + enhanced_query_for_processing=enhanced_query_for_processing, + tools=tools, + ) + yield formatted_text + return + + else: + # Find and execute the tool (decorator persists success/error) + tool_to_execute = next((t for t in tools if t.name == tool_name), None) + if tool_to_execute: + log.info(f"ChatID: {chat_id} - Executing tool: {tool_name} with args: {str(tool_args)[:100]}") + try: + # Yield tool execution status + user_friendly_tool_name = tool_name_shown_to_user(tool_name) + log.info(f"ChatID: {chat_id} - Tool name: {tool_name} - User friendly tool name: {user_friendly_tool_name}") + # Format tool query for display (same as build mode) + tool_query_str = format_tool_query_for_display(tool_name, tool_args, enhanced_query_for_processing) + stage = "retrieval_tool_execution" + content = "" + reasoning_chunk_dict = reasoning_dict_maker( + stage=stage, tool_name=user_friendly_tool_name, tool_query=tool_query_str, content=content + ) + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + + # Provide current step_order to downstream structured DB tool sub-steps + try: + if tool_name == "structured_db_tool" and isinstance(query_flow_store, dict): + query_flow_store["step_order"] = current_step + except Exception: + pass + + # Execute the tool with persistence + tool_result, current_step = await execute_tool_step( + tool_to_execute, + tool_name, + tool_args, + query_id=query_id, + chat_id=chat_id, + db=db, + current_step=current_step, + extract_facts_fn=extract_facts_from_tool_result, + ) + + # Log brief output for observability + result_preview = str(tool_result) if tool_result else "None" + log.info(f"ChatID: {chat_id} - Tool {tool_name} result preview: {result_preview[:200]}") + log.debug(f"ChatID: {chat_id} - Tool {tool_name} completed successfully") + + # Track the response + responses.append((tool_name, str(tool_args), tool_result)) + + # Create tool message for LLM + tool_message = ToolMessage(content=str(tool_result), tool_call_id=str(tool_id)) + tool_messages.append(tool_message) + + # Format tool result for display in reasoning block + if isinstance(tool_result, dict): + # Format as "message\n\nResult: {data}" so format_tool_message_for_display can clean it for users + # while the LLM gets the full structured data with UUIDs + message = tool_result.get("message", "") + # If there's a 'data' field, use it; otherwise omit the Result section (simpler format) + if "data" in tool_result and tool_result["data"]: + tool_content = f"{message}\n\nResult: {json.dumps(tool_result["data"], ensure_ascii=False)}" + else: + # No data field, just use the message + tool_content = message + else: + tool_content = str(tool_result) + + # Format and stream tool message content for user-friendly display (remove UUIDs, URLs, etc.) + cleaned_content = format_tool_message_for_display(tool_content) + if cleaned_content and cleaned_content.strip(): + stage = "retrieval_tool_execution_message" + content = f"{user_friendly_tool_name}'s result: {cleaned_content}\n\n" + reasoning_chunk_dict = reasoning_dict_maker( + stage=stage, tool_name=user_friendly_tool_name, tool_query=tool_query_str, content=content + ) + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + + except Exception as e: + log.error(f"ChatID: {chat_id} - Error executing tool {tool_name}: {str(e)}") + + # Yield error status + stage = "tool_error" + error_status_message = f"⚠️ Error executing {user_friendly_tool_name}\n\n" + reasoning_chunk_dict = reasoning_dict_maker( + stage=stage, + tool_name=user_friendly_tool_name, + tool_query=tool_query_str if "tool_query_str" in locals() else "", + content=error_status_message, + ) + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + + # Error step already persisted by decorator; step updated + + error_tool_message = ToolMessage(content=f"Error executing {tool_name}: {str(e)}", tool_call_id=str(tool_id or "")) + tool_messages.append(error_tool_message) + else: + log.error(f"ChatID: {chat_id} - Tool {tool_name} not found") + stage = "tool_unavailable" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content="") + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + error_tool_message = ToolMessage(content=f"Tool {tool_name} not found", tool_call_id=str(tool_id or "")) + tool_messages.append(error_tool_message) + + # Add tool messages to conversation + messages.extend(tool_messages) + + # Yield status before getting next LLM response + stage = "ask_mode_analyzing_results" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content="") + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + + # Get next response from LLM (persist follow-up selection/reasoning) + ai_message, current_step = await orchestrate_llm_step( + llm_with_tools, + messages, + query_id=query_id, + chat_id=chat_id, + db=db, + current_step=current_step, + include_context=False, + enhanced_conversation_history=None, + enhanced_query_for_processing=None, + tools=tools, + ) + # Handle failure case + if ai_message == "TOOL_ORCHESTRATION_FAILURE": + stage = "final_response" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content="") + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + yield "An unexpected error occurred. Please try again later." + return + + # Stream LLM's reasoning content after tool execution (internal thinking) + # Only stream as reasoning if there are more tool calls coming (not the final response) + has_more_tool_calls = bool(getattr(ai_message, "tool_calls", None)) + if has_more_tool_calls: + try: + if hasattr(ai_message, "content") and ai_message.content: + reasoning_content = str(ai_message.content).strip() + if reasoning_content: + stage = "planner_tool_selection" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content=reasoning_content) + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + except Exception: + pass + + messages.append(ai_message) + + # Follow-up selection persisted by decorator above + + # Final response from LLM (no more tool calls) + log.info(f"ChatID: {chat_id} - Tool orchestration complete, generating final response") + + # Yield final response generation status + stage = "final_response" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content="") + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + + # Stream the final answer back to caller + if ai_message.content: + final_content = str(ai_message.content) + log.info(f"ChatID: {chat_id} - Final response length: {len(final_content)} chars") + # Stream the final answer in chunks to simulate streaming + async for chunk in stream_content_in_chunks(final_content): + yield chunk + else: + log.warning(f"ChatID: {chat_id} - LLM provided no final content") + yield "I've gathered the information but couldn't generate a response. Please try again." + + except Exception as e: + log.error(f"ChatID: {chat_id} - Error in execute_tools_for_ask_mode: {str(e)}") + raise e diff --git a/apps/pi/pi/services/chat/chat.py b/apps/pi/pi/services/chat/chat.py index 27483582f6..74bde694f7 100644 --- a/apps/pi/pi/services/chat/chat.py +++ b/apps/pi/pi/services/chat/chat.py @@ -1,12 +1,15 @@ import asyncio +import json import uuid from collections.abc import AsyncIterator from typing import Any from typing import Dict from typing import List +from typing import Literal from typing import Optional from typing import Tuple from typing import Union +from typing import cast from pydantic import UUID4 from sqlmodel.ext.asyncio.session import AsyncSession @@ -14,30 +17,24 @@ from sqlmodel.ext.asyncio.session import AsyncSession from pi import logger from pi import settings from pi.app.models import Message -from pi.app.models.enums import FlowStepType from pi.app.models.enums import UserTypeChoices from pi.app.schemas.chat import ChatRequest -from pi.services.chat.utils import resolve_workspace_slug -from pi.services.feature_flags import FeatureFlagContext -from pi.services.feature_flags import feature_flag_service from pi.services.llm.error_handling import llm_error_handler from pi.services.query_utils import parse_query from pi.services.retrievers.pg_store.attachment import link_attachments_to_message -from pi.services.retrievers.pg_store.chat import check_if_chat_exists from pi.services.retrievers.pg_store.message import upsert_message -from pi.services.retrievers.pg_store.message import upsert_message_flow_steps -from pi.services.schemas.chat import AgentQuery -from pi.services.schemas.chat import Agents from pi.services.schemas.chat import QueryFlowStore +from pi.services.schemas.chat import RetrievalTools -from .helpers import action_executor -from .helpers import agent_executor -from .helpers import agent_selector +from . import action_planner +from . import askmode_tool_executor # Import helper modules +from .helpers import ask_mode_helpers from .helpers import response_processor from .helpers import title_service from .helpers import tool_utils +from .helpers.tool_utils import format_clarification_as_text # from pi.services.schemas.chat import AgentOrder from .kit import ChatKit @@ -49,9 +46,7 @@ from .templates import preset_question_flow from .utils import StandardAgentResponse from .utils import conv_history_from_app_query from .utils import is_model_enabled_for_workspace -from .utils import mask_uuids_in_text from .utils import process_conv_history -from .utils import standardize_flow_step_content log = logger.getChild(__name__) MAX_CHAT_LENGTH = settings.chat.MAX_CHAT_LENGTH @@ -87,8 +82,14 @@ class PlaneChatBot(ChatKit): is_project_chat = data.is_project_chat or False is_focus_enabled = data.workspace_in_context + # Use new polymorphic fields if available, otherwise fall back to legacy fields + focus_entity_type = getattr(data, "focus_entity_type", None) + focus_entity_id = getattr(data, "focus_entity_id", None) focus_project_id = data.project_id or None focus_workspace_id = data.workspace_id or None + mode_raw = getattr(data, "mode", "ask") or "ask" + # Ensure mode is one of the valid literal values + mode: Optional[Literal["ask", "build"]] = cast(Literal["ask", "build"], mode_raw if mode_raw in ("ask", "build") else "ask") if is_new: # For new chats, the chat record should already exist from initialize-chat endpoint # but if not, create it (backward compatibility) @@ -118,8 +119,11 @@ class PlaneChatBot(ChatKit): chat_id=chat_id, db=db, is_focus_enabled=is_focus_enabled, + focus_entity_type=focus_entity_type, + focus_entity_id=focus_entity_id, focus_project_id=focus_project_id, focus_workspace_id=focus_workspace_id, + mode=mode, ) if user_chat_preference_result["message"] != "success": return None, "An unexpected error occurred. Please try again" @@ -154,61 +158,9 @@ class PlaneChatBot(ChatKit): "workspace_in_context": workspace_in_context, } - async def _select_agents( - self, target, parsed_query, enhanced_conversation_history, workspace_in_context, query_id, chat_id, step_order, db - ) -> Tuple[List[AgentQuery], int, Union[str, None]]: - """Select appropriate agents based on query and context.""" - return await agent_selector.select_agents( - self, target, parsed_query, enhanced_conversation_history, workspace_in_context, query_id, chat_id, step_order, db - ) - - async def _execute_single_agent( + async def _execute_tools_for_build_mode( self, - agent, - sub_query, - user_meta, - message_id, - workspace_id, - project_id, - conversation_history, - user_id, - chat_id, - query_flow_store, - enhanced_query_for_processing, - query_id, - step_order, - workspace_in_context, - db, - preset_tables=None, - preset_sql_query=None, - preset_placeholders=None, - ): - """Execute a single agent and prepare response stream.""" - return await agent_executor.execute_single_agent( - self, - agent, - sub_query, - user_meta, - message_id, - workspace_id, - project_id, - conversation_history, - user_id, - chat_id, - query_flow_store, - enhanced_query_for_processing, - query_id, - step_order, - workspace_in_context, - db, - preset_tables, - preset_sql_query, - preset_placeholders, - ) - - async def _execute_action_with_retrieval( - self, - selected_agents, + selected_tools, user_meta, workspace_id, workspace_slug, @@ -226,11 +178,11 @@ class PlaneChatBot(ChatKit): is_project_chat=None, pi_sidebar_open=None, sidebar_open_url=None, - ) -> AsyncIterator[str]: - """Execute action with access to retrieval tools""" - async for chunk in action_executor.execute_action_with_retrieval( + ) -> AsyncIterator[Union[str, Dict[str, Any]]]: + """Execute tools for build mode""" + async for chunk in action_planner.execute_tools_for_build_mode( self, - selected_agents, + selected_tools, user_meta, workspace_id, workspace_slug, @@ -251,9 +203,8 @@ class PlaneChatBot(ChatKit): ): yield chunk - async def _execute_multi_agents( + async def _execute_tools_for_ask_mode( self, - selected_agents, user_meta, workspace_id, workspace_slug, @@ -267,14 +218,12 @@ class PlaneChatBot(ChatKit): query_id, step_order, db, - original_query, + parsed_query, reasoning_container=None, - workspace_in_context: bool | None = None, - ) -> AsyncIterator[str]: - """Execute multiple agents using LangChain tool calling for orchestration with real-time streaming.""" - async for chunk in agent_executor.execute_multi_agents( + ) -> AsyncIterator[Union[str, Dict[str, Any]]]: + """Execute tools for ask mode.""" + async for chunk in askmode_tool_executor.execute_tools_for_ask_mode( self, - selected_agents, user_meta, workspace_id, workspace_slug, @@ -288,19 +237,14 @@ class PlaneChatBot(ChatKit): query_id, step_order, db, - original_query, + parsed_query, reasoning_container, - workspace_in_context=workspace_in_context, ): yield chunk - def _tool_name_to_agent(self, tool_name: str) -> str: - """Convert tool name back to agent name for response formatting.""" - return tool_utils.tool_name_to_agent(tool_name) - - def _tool_name_shown_to_user(self, tool_name: str) -> str: - """Convert tool name to a user-friendly name.""" - return tool_utils.tool_name_shown_to_user(tool_name) + def _tool_name_to_retrieval_tool(self, tool_name: str) -> str: + """Convert tool name back to retrieval tool enum for response formatting.""" + return tool_utils.tool_name_to_retrieval_tool(tool_name) async def _process_attachments_for_query( self, attachment_ids: Optional[List[UUID4]], chat_id: UUID4, user_id: Optional[UUID4], query: str, query_id: UUID4, db: AsyncSession @@ -341,18 +285,24 @@ class PlaneChatBot(ChatKit): return attachment_blocks, attachment_context - def _agent_to_tool_name(self, agent_name: str) -> str: - """Convert agent name to corresponding tool name.""" - return tool_utils.agent_to_tool_name(agent_name) + def _retrieval_tool_to_tool_name(self, retrieval_tool: str) -> str: + """Convert retrieval tool enum to corresponding LangChain tool name.""" + return tool_utils.retrieval_tool_to_tool_name(retrieval_tool) - async def _process_response(self, base_stream, chat_id, query_id, response_id, switch_llm, db, reasoning=""): + async def _process_response(self, base_stream, chat_id, query_id, response_id, switch_llm, db, reasoning="", source=None): """Process streaming response and store the final result.""" - async for chunk in response_processor.process_response(base_stream, chat_id, query_id, response_id, switch_llm, db, reasoning): + async for chunk in response_processor.process_response(base_stream, chat_id, query_id, response_id, switch_llm, db, reasoning, source): yield chunk - async def process_query_stream(self, data: ChatRequest, db: AsyncSession) -> AsyncIterator[str]: + async def process_chat_stream(self, data: ChatRequest, db: AsyncSession) -> AsyncIterator[Union[str, Dict[str, Any]]]: + """Unified entry point that routes internally based on data.mode.""" + mode = getattr(data, "mode", "ask") or "ask" + async for chunk in self._process_chat_stream_core(data, db, mode=mode): + yield chunk + + async def _process_chat_stream_core(self, data: ChatRequest, db: AsyncSession, mode: str = "ask") -> AsyncIterator[Union[str, Dict[str, Any]]]: """ - This method takes a user query, processes it through various stages (parsing, routing, agent selection, agent execution), + This method takes a user query, processes it through various stages (parsing, tool selection, tool execution), and streams back the response chunks as they're generated. Steps in the Process @@ -370,23 +320,16 @@ class PlaneChatBot(ChatKit): 3. Query Processing - Parses the query to extract any specific targets (via parse_query) - Creates a user message record in the database - - Context enhancement is now integrated into the router step + - Context enhancement is now integrated into the tool selection step - 4. Agent Selection - - Determines which agent(s) should handle the query: - ~ For targeted queries, uses the appropriate agent - ~ For general queries with workspace context, uses a router to decompose the query - ~ Without workspace context, defaults to a generic agent - - Records routing decisions as flow steps + 4. Tool Selection + - Determines which tool(s) should handle the query: + ~ For targeted queries, uses the appropriate tool + ~ For general queries with workspace context, uses the retrieval tools to along with appropriate tool queries - 5. Agent Execution - - For single-agent cases: - ~ Calls the appropriate handler based on agent type - ~ Processes the response and prepares it for streaming - - For multi-agent cases: - ~ Prioritizes and executes multiple agents - ~ Collects and formats responses from all agents - ~ Combines them into a coherent answer + 5. Tool Execution + ~ Prioritizes and executes the tools + ~ Collects and formats responses from all tools 6. Response Streaming - Streams the response chunks to the user as they're generated @@ -396,6 +339,7 @@ class PlaneChatBot(ChatKit): - Creates an assistant message record with the final response - For new chats, generates a title based on the query and response """ + from pi.services.retrievers.pg_store.chat import check_if_chat_exists # Extract data query = data.query @@ -409,20 +353,11 @@ class PlaneChatBot(ChatKit): workspace_in_context = data.workspace_in_context if await is_model_enabled_for_workspace(str(chat_id), switch_llm, db) else False workspace_slug = data.workspace_slug attachment_ids = data.attachment_ids or [] - request_source = data.source step_order = 0 - # If workspace_id is None but project_id is provided, resolve workspace_id from project_id + # Resolve workspace_id from project_id if needed if not workspace_id and project_id: - try: - from pi.app.api.v1.helpers.plane_sql_queries import resolve_workspace_id_from_project_id - - resolved_workspace_id = await resolve_workspace_id_from_project_id(project_id) - if resolved_workspace_id: - workspace_id = str(resolved_workspace_id) - log.info(f"Resolved workspace_id {workspace_id} from project_id {project_id}") - except Exception as e: - log.error(f"Failed to resolve workspace_id from project_id {project_id}: {e}") + workspace_id = await ask_mode_helpers.resolve_workspace_id_from_project(project_id) # Initialize variables for use in finally block parsed_query = query @@ -430,24 +365,13 @@ class PlaneChatBot(ChatKit): reasoning = "" # Single string to collect reasoning blocks chat_exists = False - # Create or reuse message ids - # If a queued token_id is provided (from two-step flow), reuse it as the user message id - query_id = None - try: - token_id = None - if isinstance(user_meta, dict): - token_id = user_meta.get("token_id") - if token_id: - query_id = uuid.UUID(str(token_id)) - except Exception: - query_id = None - if query_id is None: - query_id = uuid.uuid4() + # Create or reuse message ids (extract from token_id if provided) + query_id = ask_mode_helpers.extract_or_create_query_id(user_meta) response_id = uuid.uuid4() # Validate chat_id is always provided if chat_id is None: - final_response = "Chat ID is required. For new chats, call /initialize-chat/ first." # Set final_response for title generation + final_response = "Chat ID is required. For new chats, call /initialize-chat/ first." yield final_response return @@ -459,7 +383,7 @@ class PlaneChatBot(ChatKit): query_flow_store = self._create_query_flow_store(data, workspace_in_context) # Parse query to detect target and get clean parsed content - target, parsed_query = parse_query(query) + _target, parsed_query = parse_query(query) # Initialize chat and get conversation history conversation_history_dict, error = await self._initialize_chat_context(data, chat_exists, db) @@ -468,34 +392,15 @@ class PlaneChatBot(ChatKit): yield error return - # Collect all the previous previous attachments to pass to llm - all_attachment_ids = attachment_ids.copy() if attachment_ids else [] - - if not data.is_new and conversation_history_dict: - # Get raw conversation history with attachments - from pi.services.retrievers.pg_store.chat import retrieve_chat_history - - raw_history = await retrieve_chat_history(chat_id=chat_id, db=db, dialogue_object=True) - - if raw_history.get("dialogue"): - # Collect ALL attachments from ALL messages in history - attachment_count = 0 - for qa_pair in raw_history["dialogue"]: - if qa_pair.get("attachments"): - for att in qa_pair["attachments"]: - att_id = att.get("id") - if att_id and att_id not in [str(aid) for aid in all_attachment_ids]: - all_attachment_ids.append(uuid.UUID(att_id)) - attachment_count += 1 - - if attachment_count > 0: - log.info(f"ChatID: {chat_id} - Collected {attachment_count} attachments from conversation history") + # Collect all attachments from conversation history + all_attachment_ids = ( + await ask_mode_helpers.collect_conversation_attachments(chat_id=chat_id, db=db, current_attachment_ids=attachment_ids) + if not data.is_new and conversation_history_dict + else (attachment_ids.copy() if attachment_ids else []) + ) if not workspace_slug: - # Use the resolved workspace_id (which might have been resolved from project_id) - # Convert string workspace_id to UUID if needed - workspace_uuid = uuid.UUID(workspace_id) if workspace_id else None - workspace_slug = await resolve_workspace_slug(workspace_uuid, data.workspace_slug) + workspace_slug = await ask_mode_helpers.resolve_workspace_slug_if_needed(workspace_id, data.workspace_slug) # TODO: Include storing parent_id as well # Reuse existing message row if query_id originated from a queued token; otherwise insert a new row. @@ -507,6 +412,7 @@ class PlaneChatBot(ChatKit): user_type=UserTypeChoices.USER.value, llm_model=switch_llm, workspace_slug=workspace_slug, + source=getattr(data, "source", None) or None, db=db, ) @@ -521,7 +427,7 @@ class PlaneChatBot(ChatKit): all_attachment_ids, chat_id, user_id, parsed_query, query_id, db ) - # Create enhanced query for routing/agent selection if we have attachment context + # Create enhanced query for tool selection if we have attachment context # But keep parsed_query clean for database storage enhanced_query_for_processing = self.enhance_query_with_context(parsed_query, attachment_context) if attachment_context: @@ -531,20 +437,17 @@ class PlaneChatBot(ChatKit): if data.attachment_ids: await link_attachments_to_message(attachment_ids=data.attachment_ids, message_id=query_id, chat_id=data.chat_id, user_id=user_id, db=db) - # log input and other important info: - log.info(f"ChatID: {chat_id} - Input query: {query}") - log.info(f"ChatID: {chat_id} - Enhanced query: {enhanced_query_for_processing}") - log.info(f"ChatID: {chat_id} - Attachment context: {attachment_context}") - log.info(f"ChatID: {chat_id} - User meta: {user_meta}") - log.info(f"ChatID: {chat_id} - Workspace in context: {workspace_in_context}") - log.info(f"ChatID: {chat_id} - Workspace slug: {workspace_slug}") - log.info(f"ChatID: {chat_id} - Workspace ID: {workspace_id}") - log.info(f"ChatID: {chat_id} - Is New Chat: {data.is_new}") - log.info(f"ChatID: {chat_id} - Source: {data.source}") - log.info(f"ChatID: {chat_id} - Is Project Chat: {data.is_project_chat}") - log.info(f"ChatID: {chat_id} - User ID: {user_id}") - log.info(f"ChatID: {chat_id} - Project ID: {project_id}") - log.info(f"ChatID: {chat_id} - LLM: {switch_llm}") + # Log input and other important info + ask_mode_helpers.log_ask_mode_request_details( + data, + { + "enhanced_query_for_processing": enhanced_query_for_processing, + "attachment_context": attachment_context, + "workspace_in_context": workspace_in_context, + "workspace_slug": workspace_slug, + "workspace_id": workspace_id, + }, + ) # Handle case where conversation_history_dict might be None or a list if data.source == "app": @@ -560,55 +463,11 @@ class PlaneChatBot(ChatKit): conversation_history = conversation_history_dict["langchain_conv_history"] enhanced_conversation_history = conversation_history_dict["enhanced_conv_history"] + # Check for pending clarifications in the `data.is_new=False` case if not data.is_new: - try: - # If a clarification is pending, enrich user_meta with context from message_clarifications - from pi.services.retrievers.pg_store.clarifications import get_latest_pending_for_chat as _get_pending_clar - - log.info(f"ChatID: {chat_id} - Checking for pending clarifications...") - clar_row = await _get_pending_clar(db=db, chat_id=uuid.UUID(str(chat_id))) - if clar_row: - clar_payload = clar_row.payload or {} - log.info( - f"ChatID: {chat_id} - Found pending clarification, enriching user_meta. Kind: {clar_row.kind}, Categories: {clar_row.categories}" # noqa: E501 - ) - user_meta["clarification_context"] = { - "reason": clar_payload.get("reason"), - "missing_fields": clar_payload.get("missing_fields") or [], - "disambiguation_options": clar_payload.get("disambiguation_options") or [], - "category_hints": clar_payload.get("category_hints") or [], - "answer_text": parsed_query, - "original_query": clar_row.original_query, # CRITICAL: Include original query for full context - "clarifies_message_id": str(clar_row.message_id), - } - log.info(f"ChatID: {chat_id} - Enriched user_meta with clarification_context") - # Record a clarification_response flow step for traceability (not marking resolved yet) - try: - await upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=[ - { - "step_order": step_order + 1, - "step_type": FlowStepType.TOOL.value, - "tool_name": "clarification_response", - "content": standardize_flow_step_content(user_meta.get("clarification_context", {}), FlowStepType.TOOL), - "execution_data": { - "clarifies_message_id": str(clar_row.message_id), - "clarification_resolved": False, - }, - } - ], - db=db, - ) - # Push subsequent steps by +1 - step_order = step_order + 2 - except Exception: - pass - else: - log.info(f"ChatID: {chat_id} - No pending clarification found") - except Exception as _e: - log.warning(f"ChatID: {chat_id} - Clarification table check failed: {_e}") + user_meta, step_order = await ask_mode_helpers.check_and_enrich_clarification_context( + chat_id=chat_id, db=db, user_meta=user_meta, parsed_query=parsed_query, query_id=query_id, step_order=step_order + ) # Check if the query is a preset question preset_query_steps = preset_question_flow(query) @@ -623,328 +482,184 @@ class PlaneChatBot(ChatKit): # Set attachment blocks context for tool execution self._current_attachment_blocks = attachment_blocks - reasoning_chunk = "🤖 Understanding the query...\n\n" - reasoning += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - # Query already parsed above, just set step_order step_order = 1 - reasoning_chunk = "🤖 Curating the ideal response path...\n\n" - reasoning += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - - # Select appropriate agents + # Handle preset query flow if applicable if preset_query_steps: - # Use preset agents instead of routing - selected_agents = [] - for step in preset_query_steps: - reasoning_messages = step.get("reasoning_messages", []) - agents = step.get("agents", []) - - # Show preset reasoning messages - for reasoning_msg in reasoning_messages: - masked_reasoning_msg = mask_uuids_in_text(reasoning_msg) - reasoning_chunk = f"{masked_reasoning_msg}\n\n" - reasoning += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - - # Convert preset agents to AgentQuery format - for agent_config in agents: - agent_name = agent_config.get("agent") - agent_query = agent_config.get("query") - if agent_name and agent_query: - selected_agents.append(AgentQuery(agent=agent_name, query=agent_query)) - - # Record preset routing as flow step - if selected_agents: - routing_content = [{"tool": agent.agent.name, "query": agent.query} for agent in selected_agents] - flow_step_result = await upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=[ - { - "step_order": step_order, - "step_type": FlowStepType.ROUTING.value, - "tool_name": None, - "content": standardize_flow_step_content(routing_content, FlowStepType.ROUTING), - "execution_data": {}, - } - ], - db=db, - ) - - # Ensure routing flow step insert cannot stall the stream - try: - flow_step_result = await asyncio.wait_for( - upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=[ - { - "step_order": step_order, - "step_type": FlowStepType.ROUTING.value, - "tool_name": None, - "content": standardize_flow_step_content(routing_content, FlowStepType.ROUTING), - "execution_data": {}, - } - ], - db=db, - ), - timeout=2.0, - ) - except asyncio.TimeoutError: - log.warning("Timed out recording RAG routing flow step; continuing") - flow_step_result = {"message": "error", "error": "timeout"} - - if flow_step_result["message"] != "success": - # Log the error but continue - flow step logging is not critical for user experience - log.warning(f"Failed to record RAG routing flow step: {flow_step_result.get("error", "Unknown error")}") - # Continue with response generation - user should still get their answer - - step_order += 1 - error = None - elif isinstance(user_meta, dict) and user_meta.get("clarification_context"): - # Skip routing for clarification follow-up and use deterministic clarification record - log.info(f"ChatID: {chat_id} - Processing clarification follow-up, skipping router") - try: - from pi.services.retrievers.pg_store.clarifications import get_latest_pending_for_chat - from pi.services.retrievers.pg_store.clarifications import resolve_clarification - - selected_agents = [] - clar_row = await get_latest_pending_for_chat(db=db, chat_id=uuid.UUID(str(chat_id))) - - if clar_row: - # Build agents based on stored kind - if (clar_row.kind or "").lower() == "action": - selected_agents.append(AgentQuery(agent=Agents.PLANE_ACTION_EXECUTOR_AGENT, query=parsed_query)) - flow_kind = "action" - else: - for agent_name in clar_row.categories or []: - selected_agents.append(AgentQuery(agent=agent_name, query=parsed_query)) - flow_kind = "retrieval" - - # Record skipped routing as flow step - routing_content = [{"tool": agent.agent, "query": agent.query} for agent in selected_agents] - flow_step_result = await upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=[ - { - "step_order": step_order, - "step_type": FlowStepType.ROUTING.value, - "tool_name": "clarification_skip_router", - "content": standardize_flow_step_content(routing_content, FlowStepType.ROUTING), - "execution_data": { - "skipped_routing": True, - "source": "clarification_followup", - "flow": flow_kind, - "raw_user_input": parsed_query, - }, - } - ], - db=db, - ) - if flow_step_result["message"] != "success": - log.warning("Failed to record skipped routing flow step") - step_order += 1 - - # Mark clarification as resolved - try: - await resolve_clarification( - db, - clarification_id=clar_row.id, - answer_text=parsed_query, - resolved_by_message_id=query_id, - ) - except Exception as e: - log.warning(f"ChatID: {chat_id} - Failed to resolve clarification: {e}") - - error = None - else: - # No clarification record found - fall back to regular routing - selected_agents, step_order, error = await self._select_agents( - target, - enhanced_query_for_processing, - enhanced_conversation_history, - workspace_in_context, - query_id, - chat_id, - step_order, - db, - ) - - except Exception as e: - log.error(f"ChatID: {chat_id} - Error processing clarification follow-up: {e}") - selected_agents, step_order, error = await self._select_agents( - target, enhanced_query_for_processing, enhanced_conversation_history, workspace_in_context, query_id, chat_id, step_order, db - ) - else: - # Regular agent selection - selected_agents, step_order, error = await self._select_agents( - target, enhanced_query_for_processing, enhanced_conversation_history, workspace_in_context, query_id, chat_id, step_order, db - ) - - if error: - # Store error message - await upsert_message( - message_id=response_id, + reasoning_container = {"content": reasoning} + async for chunk in ask_mode_helpers.handle_preset_query_flow( + chatbot_instance=self, + preset_query_steps=preset_query_steps, + query_id=query_id, chat_id=chat_id, - content=error, - user_type=UserTypeChoices.ASSISTANT.value, - parent_id=query_id, - llm_model=switch_llm, - reasoning=reasoning, + step_order=step_order, db=db, - ) + user_meta=user_meta, + workspace_id=workspace_id or "", + project_id=project_id or "", + conversation_history=conversation_history, + user_id=str(user_id) if user_id else "", + query_flow_store=query_flow_store, + enhanced_query_for_processing=enhanced_query_for_processing, + workspace_in_context=workspace_in_context, + switch_llm=switch_llm, + reasoning_container=reasoning_container, + source=getattr(data, "source", None) or None, + ): + yield chunk - # Error message search index upserted via Celery background task - - final_response = error # Set final_response for title generation - yield error return - # Check if action execution agent is present - has_action_agent = any(agent.agent == Agents.PLANE_ACTION_EXECUTOR_AGENT for agent in selected_agents) - # Execute agent(s) and get response stream - if has_action_agent: - ### add feature flag check here - # Create feature flag context with user and workspace information - try: - feature_context = FeatureFlagContext(user_id=str(user_id), workspace_slug=workspace_slug) - # log.info(f"Checking action execution feature flag for user {user_id} in workspace {workspace_slug}") - is_action_execution_enabled = await feature_flag_service.is_action_execution_enabled(feature_context) - # log.info(f"Action execution feature flag result: {is_action_execution_enabled}") - except Exception as e: - log.error(f"Error checking action execution feature flag: {e}") - # Default to disabled if feature flag check fails - is_action_execution_enabled = False - if not is_action_execution_enabled: - # Feature flag check failed - show message that action execution is not available - # This prevents the action execution agent from running when the feature is disabled - reasoning_chunk = "🎯 Planning to execute action..\n\n" - reasoning += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" + ## Resolve pending clarification if this is a follow-up + if isinstance(user_meta, dict) and user_meta.get("clarification_context"): + await ask_mode_helpers.resolve_pending_clarification(chat_id=chat_id, db=db, parsed_query=parsed_query, query_id=query_id) - # Yield final response status to signal end of reasoning - reasoning_chunk = "📝 Generating final response...\n\n" - reasoning += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" + # Continue with normal tool execution flow for both normal queries and clarification follow-ups + # Clarification context is already in user_meta and the execution methods will handle it properly + if not preset_query_steps: + # Multi-tool execution - stream tool execution details and final response + final_response_chunks: List[Union[str, Dict[str, Any]]] = [] + collecting_final_response = False - # Add a small delay to ensure chunks are processed separately - await asyncio.sleep(0.01) - # Now yield the actual response content (not during reasoning) - # static_response = "Action execution is not available yet in your workspace. Please contact your administrator to enable this feature. In the meantime, I can help you with any other questions." # noqa E501 + # Create a container for reasoning to allow modification by reference + reasoning_container = {"content": reasoning} + clarification_saved_multi = False - static_response = ( - "Action execution is not available yet. It is coming soon. In the meantime, I can help you with any other questions." # noqa E501 - ) - yield static_response - - # Save the response to database - assistant_message_result = await upsert_message( - message_id=response_id, + # No-workspace fast path: direct LLM (no tools) + if not workspace_in_context: + execution_stream = ask_mode_helpers.create_fast_path_stream( + chatbot_instance=self, + query_id=query_id, + db=db, chat_id=chat_id, - content=static_response, - user_type=UserTypeChoices.ASSISTANT, - parent_id=query_id, - llm_model=switch_llm, - reasoning=reasoning, + user_meta=user_meta, + user_id=str(user_id) if user_id else None, + enhanced_query_for_processing=enhanced_query_for_processing, + enhanced_conversation_history=enhanced_conversation_history, + reasoning_container=reasoning_container, + ) + + # Mode-specific execution branch + elif mode == "build": + # Build mode: Action planning with user approval + log.info(f"ChatID: {chat_id} - Using BUILD mode (action planning)") + + # Early OAuth check for build mode (before building any tools) + from pi.services.chat.helpers.build_mode_helpers import check_oauth_for_build_mode + + oauth_message = await check_oauth_for_build_mode( + chatbot_instance=self, + user_id=str(user_id), + workspace_id=workspace_id, + workspace_slug=workspace_slug, + project_id=project_id, + chat_id=str(chat_id), + query_id=query_id, + step_order=step_order, + combined_tool_query=enhanced_query_for_processing, + enhanced_conversation_history=enhanced_conversation_history, + is_project_chat=data.is_project_chat, + pi_sidebar_open=data.pi_sidebar_open, + sidebar_open_url=data.sidebar_open_url, db=db, ) - if assistant_message_result["message"] != "success": - yield "An unexpected error occurred. Please try again" + if oauth_message: + # OAuth required - stream message and return early + yield oauth_message - # Static response search index upserted via Celery background task + # Store the final response manually since we're returning early + assistant_message_result = await upsert_message( + message_id=response_id, + chat_id=chat_id, + content=oauth_message, + user_type=UserTypeChoices.ASSISTANT.value, + parent_id=query_id, + llm_model=switch_llm, + reasoning=reasoning_container.get("content", reasoning) if reasoning_container else reasoning, + source=getattr(data, "source", None) or None, + db=db, + ) - final_response = static_response + if assistant_message_result["message"] != "success": + log.warning(f"ChatID: {chat_id} - Failed to store OAuth message in database") - elif request_source == "mobile": - static_response = "Action execution is not available yet in the mobile app. This feature is coming soon! Meanwhile, you can try it on the web app, or let me know how else I can assist you." # noqa E501 - yield static_response + # Update query flow store + query_flow_store["answer"] = oauth_message + return - assistant_message_result = await upsert_message( - message_id=response_id, - chat_id=chat_id, - content=static_response, - user_type=UserTypeChoices.ASSISTANT, - parent_id=query_id, - llm_model=switch_llm, - reasoning=reasoning, + # OAuth check passed - proceed with build mode execution + execution_stream = self._execute_tools_for_build_mode( + selected_tools=[], # Build mode determines tools dynamically + user_meta=user_meta, + workspace_id=workspace_id, + workspace_slug=workspace_slug, + project_id=project_id, + conversation_history=conversation_history, + enhanced_conversation_history=enhanced_conversation_history, + user_id=str(user_id), + chat_id=str(chat_id), + query_flow_store=query_flow_store, + parsed_query=enhanced_query_for_processing, + query_id=query_id, + step_order=step_order, db=db, + reasoning_container=reasoning_container, + is_project_chat=data.is_project_chat, + pi_sidebar_open=data.pi_sidebar_open, + sidebar_open_url=data.sidebar_open_url, ) - - if assistant_message_result["message"] != "success": - yield "An unexpected error occurred. Please try again" - - final_response = static_response - else: - # Feature flag check passed - proceed with action execution - # Execute agent(s) and get response stream - # Special handling for action execution agent (with or without retrieval tools) - reasoning_chunk = "🎯 Planning to execute action using:\n\n" - reasoning += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - - agent_queries = [] - for i, agent in enumerate(selected_agents, 1): - tool_name = self._agent_to_tool_name(agent.agent) - user_friendly_name = self._tool_name_shown_to_user(tool_name) - agent_query = agent.query - masked_agent_query = mask_uuids_in_text(agent_query) - agent_queries.append(agent_query) - if tool_name == "action_executor_agent": # Updated to match new naming - reasoning_chunk = f"   {i}. {user_friendly_name} to process the request: '{masked_agent_query}'\n\n" - else: - reasoning_chunk = f"   {i}. {user_friendly_name} to get details about: '{masked_agent_query}'\n\n" - reasoning += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - - # Action execution with hierarchical flow - stream tool execution details and final response - final_response_chunks = [] - collecting_final_response = False - final_response = "" # Initialize final_response variable - - # Create a container for reasoning to allow modification by reference - reasoning_container = {"content": reasoning} - combined_agent_query = "\n".join(agent_queries) - clarification_saved = False - async for chunk in self._execute_action_with_retrieval( - selected_agents, + # Ask mode: Retrieval and answering + log.info(f"ChatID: {chat_id} - Using ASK mode (retrieval only)") + execution_stream = self._execute_tools_for_ask_mode( user_meta, workspace_id, workspace_slug, project_id, conversation_history, - enhanced_conversation_history, # 🔥 NEW: Pass enhanced context separately + enhanced_conversation_history, str(user_id), str(chat_id), query_flow_store, - combined_agent_query, + enhanced_query_for_processing, query_id, step_order, db, + parsed_query, reasoning_container=reasoning_container, - is_project_chat=data.is_project_chat, - pi_sidebar_open=data.pi_sidebar_open, - sidebar_open_url=data.sidebar_open_url, - ): # type: ignore - # Persist clarification as assistant message when encountered - if not clarification_saved and chunk.startswith("πspecial clarification blockπ: "): + ) + + async for chunk in execution_stream: + if isinstance(chunk, dict): + if "chunk_type" in chunk and chunk["chunk_type"] == "reasoning": + header_text = (chunk.get("header") or "").strip() + stage_text = chunk.get("stage") or "" + if stage_text == "final_response" or header_text.startswith("📝 Generating final response..."): + collecting_final_response = True + + yield chunk + continue + # elif 'chunk_type' in chunk and chunk['chunk_type'] == 'actions': + # yield chunk + # continue + # elif 'chunk_type' in chunk and chunk['chunk_type'] == 'clarification': + # yield chunk + # continue + # Persist clarification as assistant message when encountered + elif isinstance(chunk, str): + if not clarification_saved_multi and chunk.startswith("πspecial clarification blockπ: "): try: - import json as _json - - from pi.services.chat.helpers.tool_utils import format_clarification_as_text as _fmt_clar - clar_content = chunk.replace("πspecial clarification blockπ: ", "") + log.info(f"ChatID: {chat_id} - Clarification content in the original chunk from the tool execution: {clar_content}") try: - clar_data = _json.loads(clar_content) - except Exception: + clar_data = json.loads(clar_content) + except json.JSONDecodeError: + log.warning(f"ChatID: {chat_id} - Failed to parse clarification JSON: {clar_content}") clar_data = {"raw": clar_content} - clar_text = _fmt_clar(clar_data) - # Save the clarification prompt as an assistant message + clar_text = format_clarification_as_text(clar_data) + log.info(f"ChatID: {chat_id} - Clarification in text format: {clar_text}") await upsert_message( message_id=response_id, chat_id=chat_id, @@ -953,12 +668,26 @@ class PlaneChatBot(ChatKit): parent_id=query_id, llm_model=switch_llm, reasoning=reasoning_container.get("content", reasoning), + source=getattr(data, "source", None) or None, db=db, ) except Exception: pass finally: - clarification_saved = True + clarification_saved_multi = True + if chunk.startswith("__FINAL_RESPONSE__"): + final_response = chunk[len("__FINAL_RESPONSE__") :] + # Do not yield marker chunks to the client + continue + + # Build mode: Collect action summaries as final response content + # Action summaries are streamed with special markers + if mode == "build" and chunk.startswith("πspecial action summaryπ:"): + # Stream to client for UI rendering + yield chunk + # Also collect for final response storage + final_response_chunks.append(chunk) + continue # Check if this chunk indicates we're starting the final response if chunk == "πspecial reasoning blockπ: 📝 Generating final response...\n\n": @@ -966,194 +695,6 @@ class PlaneChatBot(ChatKit): yield chunk continue - # Check if this chunk contains the final response signal from action executor - if chunk.startswith("__FINAL_RESPONSE__"): - # Extract the final response from the special signal - final_response = chunk[len("__FINAL_RESPONSE__") :] - continue - - # If we're collecting the final response (after the status message) - if collecting_final_response: - final_response_chunks.append(chunk) - - yield chunk - - # Update reasoning from the container - reasoning = reasoning_container["content"] - - # If we didn't get a final response signal, combine the collected chunks - if not final_response: - final_response = "".join(final_response_chunks) - - # Save assistant message with reasoning blocks - if final_response: - assistant_message_result = await upsert_message( - message_id=response_id, - chat_id=chat_id, - content=final_response, - user_type=UserTypeChoices.ASSISTANT, - parent_id=query_id, - llm_model=switch_llm, - reasoning=reasoning, - db=db, - ) - - if assistant_message_result["message"] != "success": - final_response = "An unexpected error occurred. Please try again" - yield final_response - - # log.info(f"ChatID: {chat_id} - Final Response: {final_response}") - - elif len(selected_agents) == 1 and (selected_agents[0].agent == "generic_agent" or preset_query_steps): - # Handle only generic agent or preset queries with single agent execution - # Get preset parameters if available - preset_tables = None - preset_sql_query = None - preset_placeholders = None - if preset_query_steps and selected_agents[0].agent == "plane_structured_database_agent": - for step in preset_query_steps: - agents = step.get("agents", []) - for agent_config in agents: - if agent_config.get("agent") == "plane_structured_database_agent": - preset_tables = agent_config.get("tables", []) - preset_sql_query = agent_config.get("sql_query") - preset_placeholders = agent_config.get("placeholders_in_order", []) - break - if preset_sql_query: - break - base_stream, error = await self._execute_single_agent( - selected_agents[0].agent, - selected_agents[0].query, - user_meta, - query_id, - workspace_id, - project_id, - conversation_history, - str(user_id), - str(chat_id), - query_flow_store, - enhanced_query_for_processing, - query_id, - step_order, - workspace_in_context, - db, - preset_tables, - preset_sql_query, - preset_placeholders, - ) - if error: - # Store error message - await upsert_message( - message_id=response_id, - chat_id=chat_id, - content=error, - user_type=UserTypeChoices.ASSISTANT.value, - parent_id=query_id, - llm_model=switch_llm, - reasoning=reasoning, - db=db, - ) - - # Error message search index upserted via Celery background task - - final_response = error # Set final_response for title generation - yield error - return - - # Yield final response status - reasoning_chunk = "📝 Generating final response...\n\n" - reasoning += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - # Process and stream response - final_response = "" - async for chunk in self._process_response(base_stream, chat_id, query_id, response_id, switch_llm, db, reasoning): - if chunk.startswith("__FINAL_RESPONSE__"): - # Extract the final response from the special signal - final_response = chunk[len("__FINAL_RESPONSE__") :] - else: - yield chunk - - else: - # Stream selected agents - reasoning_chunk = "🎯 Planning to execute the below steps:\n\n" - reasoning += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - - for i, agent in enumerate(selected_agents, 1): - tool_name = self._agent_to_tool_name(agent.agent) - user_friendly_name = self._tool_name_shown_to_user(tool_name) - masked_agent_query = mask_uuids_in_text(agent.query) - reasoning_chunk = f"   {i}. {user_friendly_name} to get details about: '{masked_agent_query}'\n\n" - reasoning += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - - # Multi-agent execution - stream tool execution details and final response - final_response_chunks = [] - collecting_final_response = False - - # Create a container for reasoning to allow modification by reference - reasoning_container = {"content": reasoning} - clarification_saved_multi = False - - async for chunk in self._execute_multi_agents( - selected_agents, - user_meta, - workspace_id, - workspace_slug, - project_id, - conversation_history, - enhanced_conversation_history, - str(user_id), - str(chat_id), - query_flow_store, - enhanced_query_for_processing, - query_id, - step_order, - db, - parsed_query, - reasoning_container=reasoning_container, - workspace_in_context=workspace_in_context, - ): - # Persist clarification as assistant message when encountered - if not clarification_saved_multi and chunk.startswith("πspecial clarification blockπ: "): - try: - import json as _json - - from pi.services.chat.helpers.tool_utils import format_clarification_as_text as _fmt_clar - - clar_content = chunk.replace("πspecial clarification blockπ: ", "") - try: - clar_data = _json.loads(clar_content) - except Exception: - clar_data = {"raw": clar_content} - clar_text = _fmt_clar(clar_data) - await upsert_message( - message_id=response_id, - chat_id=chat_id, - content=clar_text, - user_type=UserTypeChoices.ASSISTANT, - parent_id=query_id, - llm_model=switch_llm, - reasoning=reasoning_container.get("content", reasoning), - db=db, - ) - except Exception: - pass - finally: - clarification_saved_multi = True - - # Handle explicit final response marker (defensive) - if chunk.startswith("__FINAL_RESPONSE__"): - final_response = chunk[len("__FINAL_RESPONSE__") :] - # Do not yield marker chunks to the client - continue - - # Check if this chunk indicates we're starting the final response - if chunk == "πspecial reasoning blockπ: 📝 Generating final response...\n\n": - collecting_final_response = True - yield chunk - continue - # If we're collecting the final response (after the status message) if collecting_final_response: final_response_chunks.append(chunk) @@ -1163,8 +704,11 @@ class PlaneChatBot(ChatKit): # Update reasoning from the container reasoning = reasoning_container["content"] - # Combine the final response chunks - final_response = "".join(final_response_chunks) + # Combine the final response chunks if present; otherwise, keep captured __FINAL_RESPONSE__ + if final_response_chunks: + # Filter out dict chunks (reasoning blocks) and join only string chunks + string_chunks = [chunk for chunk in final_response_chunks if isinstance(chunk, str)] + final_response = "".join(string_chunks) # Save assistant message with reasoning blocks if final_response: @@ -1176,6 +720,7 @@ class PlaneChatBot(ChatKit): parent_id=query_id, llm_model=switch_llm, reasoning=reasoning, + source=getattr(data, "source", None) or None, db=db, ) @@ -1208,6 +753,7 @@ class PlaneChatBot(ChatKit): parent_id=query_id, llm_model=switch_llm, reasoning=reasoning, + source=getattr(data, "source", None) or None, db=db, ) ) @@ -1228,6 +774,7 @@ class PlaneChatBot(ChatKit): parent_id=query_id, llm_model=switch_llm, reasoning=reasoning, + source=getattr(data, "source", None) or None, db=db, ) @@ -1237,78 +784,24 @@ class PlaneChatBot(ChatKit): finally: # Generate chat title for new chats BEFORE clearing token tracking context - # Check if this is the first message by looking at sequence number - try: - # Import here to avoid circular dependency - from sqlalchemy import select - - from pi.app.models import Message - - # Get the message sequence number to determine if this is a new chat - stmt = select(Message).where(Message.id == query_id) # type: ignore[arg-type] - result = await db.execute(stmt) - message = result.scalar_one_or_none() - - if message: - message_sequence = message.sequence - is_first_message = message_sequence == 1 - else: - log.warning(f"ChatID: {chat_id} - Message {query_id} not found for sequence check") - message_sequence = None - is_first_message = False - - if is_first_message: - try: - # For error scenarios, use the user's question directly as title - is_error_response = ( - not final_response - or final_response.startswith("An unexpected error occurred") - or final_response == "TOOL_ORCHESTRATION_FAILURE" - or final_response.startswith("Action execution is not available") - or final_response.startswith("Chat ID is required") - or final_response.startswith("Your request timed out") - ) - - if is_error_response: - # Use the user's original question as title, with some cleanup - title = (parsed_query if parsed_query is not None else query).strip() - # Truncate if too long (keeping it under ~60 characters for readability) - if len(title) > 60: - title = title[:57] + "..." - await self.set_chat_title_directly(chat_id, title, db) - elif final_response: - # For successful responses, generate title using LLM - chat_history = [parsed_query if parsed_query is not None else query, final_response] - # Ensure both elements are strings - chat_history = [str(item) for item in chat_history if item is not None] - if len(chat_history) >= 2: # Make sure we have both query and response - title = await self.generate_title(chat_id, chat_history, db) - # Generated chat title search index upserted via Celery background task - else: - log.warning(f"ChatID: {chat_id} - Not enough valid content to generate title") - title = (parsed_query if parsed_query is not None else query).strip() - # Truncate if too long (keeping it under ~60 characters for readability) - if len(title) > 60: - title = title[:57] + "..." - await self.set_chat_title_directly(chat_id, title, db) - else: - log.warning(f"ChatID: {chat_id} - No final_response available for title generation") - if title: - log.info(f"ChatID: {chat_id} - Generated title: {title}") - except Exception as e: - log.error(f"Error generating title: {str(e)}") - - except Exception as e: - log.error(f"Error checking message sequence for title generation: {str(e)}") + await ask_mode_helpers.generate_chat_title_if_needed( + chatbot_instance=self, + chat_id=chat_id, + query_id=query_id, + db=db, + final_response=final_response, + parsed_query=parsed_query, + query=query, + ) # Clear token tracking context and attachment blocks self.clear_token_tracking_context() self._current_attachment_blocks = None # type: ignore[assignment] - async def handle_agent_query( + async def handle_tool_query( self, db: AsyncSession, - agent: str, + tool: str, query: str, user_meta: dict, message_id: UUID4, @@ -1320,7 +813,7 @@ class PlaneChatBot(ChatKit): query_flow_store: QueryFlowStore, vector_search_issue_ids: list[str] | None = None, vector_search_page_ids: list[str] | None = None, - is_multi_agent: bool | None = False, + is_multi_tool: bool | None = False, preset_tables: list[str] | None = None, preset_sql_query: str | None = None, preset_placeholders: list[str] | None = None, @@ -1330,12 +823,7 @@ class PlaneChatBot(ChatKit): if vector_search_page_ids is None: vector_search_page_ids = [] - if agent == Agents.GENERIC_AGENT: - # Get attachment blocks from the main processing context - attachment_blocks = self.get_current_attachment_blocks() - return await self.handle_generic_query(query, user_id, conv_hist, attachment_blocks=attachment_blocks) - - if agent == Agents.PLANE_STRUCTURED_DATABASE_AGENT: + if tool == RetrievalTools.STRUCTURED_DB_TOOL: # Convert conversation history to list of strings for conv_history parameter conv_history_strings = [] if conv_hist: @@ -1359,7 +847,7 @@ class PlaneChatBot(ChatKit): chat_id=str(chat_id), vector_search_issue_ids=vector_search_issue_ids, vector_search_page_ids=vector_search_page_ids, - is_multi_agent=is_multi_agent, + is_multi_tool=is_multi_tool, user_meta=user_meta, conv_history=conv_history_strings, preset_tables=preset_tables, @@ -1367,20 +855,14 @@ class PlaneChatBot(ChatKit): preset_placeholders=preset_placeholders, attachment_blocks=attachment_blocks, ) - if agent == Agents.PLANE_VECTOR_SEARCH_AGENT: + if tool == RetrievalTools.VECTOR_SEARCH_TOOL: return await self.handle_vector_search_query(query, workspace_id, project_id, user_id, vector_search_issue_ids) - if agent == Agents.PLANE_PAGES_AGENT: + if tool == RetrievalTools.PAGES_SEARCH_TOOL: return await self.handle_pages_query(query, workspace_id, project_id, user_id, vector_search_page_ids) - if agent == Agents.PLANE_DOCS_AGENT: + if tool == RetrievalTools.DOCS_SEARCH_TOOL: return await self.handle_docs_query(query) - if agent == Agents.PLANE_ACTION_EXECUTOR_AGENT: - # Action execution is now handled through the hierarchical flow in process_query_stream - # This path should not be reached anymore - log.warning("handle_agent_query called for action executor - this should be handled in process_query_stream") - return StandardAgentResponse.create_response("Action execution should be handled through the main query processing flow.") - else: - log.error(f"Unknown agent type encountered: {agent}. Fallback to generic agent.") - return await self.handle_generic_query(query, user_id, conv_hist) # fallback to generic agent + + return StandardAgentResponse.create_response("Sorry, I couldn't retrieve the information you asked for at this time. Please try again later.") async def generate_title(self, chat_id: UUID4, chat_history: list[str], db: AsyncSession) -> str: """Generate a title for a chat using the first question-answer pair and update it in the database.""" diff --git a/apps/pi/pi/services/chat/helpers/action_execution_helpers.py b/apps/pi/pi/services/chat/helpers/action_execution_helpers.py new file mode 100644 index 0000000000..6bfe1141aa --- /dev/null +++ b/apps/pi/pi/services/chat/helpers/action_execution_helpers.py @@ -0,0 +1,421 @@ +"""Helper functions for batch action execution.""" + +import json +from datetime import datetime +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple +from uuid import UUID + +from sqlalchemy import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from pi import logger +from pi.app.models.message import Message +from pi.app.models.message import MessageFlowStep +from pi.app.schemas.chat import ArtifactData +from pi.services.chat.helpers.build_mode_helpers import TOOL_NAME_TO_CATEGORY_MAP +from pi.services.chat.helpers.tool_utils import classify_tool +from pi.services.chat.prompts import plane_context +from pi.services.retrievers.pg_store.action_artifact import get_action_artifacts_by_ids + +log = logger.getChild(__name__) + + +async def load_artifacts(request_data: List[ArtifactData], db: AsyncSession) -> Tuple[List[Dict], str, str]: + """Load and validate artifacts for execution.""" + artifacts = await get_action_artifacts_by_ids(db, [a.artifact_id for a in request_data]) + + original_query = artifacts[0].data.get("planning_context", {}).get("original_query", "") + conversation_context = artifacts[0].data.get("planning_context", {}).get("conversation_context", {}) + + planned_actions = [] + for artifact, req_item in zip(artifacts, request_data): + if req_item.is_edited: + tool_args = req_item.action_data + # TODO: validate tool args + else: + tool_args = artifact.data.get("tool_args_raw", {}) + planned_actions.append({ + "artifact_id": str(artifact.id), + "tool_name": artifact.data.get("planning_data", {}).get("tool_name", ""), + "args": tool_args, + "entity_type": artifact.data.get("planning_data", {}).get("artifact_type", ""), + "action": artifact.data.get("planning_data", {}).get("action", ""), + }) + + return planned_actions, original_query, conversation_context + + +async def update_flow_steps(results, message_id, chat_id, db: AsyncSession): + """Mark executed actions in database.""" + from pi.services.retrievers.pg_store.action_artifact import update_action_artifact_execution_status + + for r in results: + artifact_id = r.get("artifact_id") + if artifact_id: + artifact_uuid = UUID(artifact_id) + + # Extract entity_id from entity_info if available + entity_info = r.get("entity_info") + entity_id = None + if entity_info and isinstance(entity_info, dict): + # Try to get entity_id from entity_info + entity_id_str = entity_info.get("entity_id") + if entity_id_str: + try: + entity_id = UUID(entity_id_str) + except (ValueError, TypeError): + log.warning(f"Invalid entity_id format in entity_info: {entity_id_str}") + + # Update ActionArtifact and MessageFlowStep execution status + await update_action_artifact_execution_status( + db=db, + message_id=message_id, + chat_id=chat_id, + artifact_id=artifact_uuid, + is_executed=True, + success=r.get("success", False), + entity_id=entity_id, + entity_info=entity_info, + execution_result=r.get("result", ""), + executed_at=r.get("executed_at"), + ) + + +def format_response(planned_actions, results, start_time) -> Dict[str, Any]: + """Format the execution response with clean, non-redundant structure.""" + log.info(f"\n\nResults in format_response: {results}\n") + try: + total_planned = len(planned_actions) + completed_count = sum(1 for r in results if r.get("success")) + failed_count = sum(1 for r in results if r.get("success") is False) + response: Dict[str, Any] = { + "action_summary": { + "total_planned": total_planned, + "completed": completed_count, + "failed": failed_count, + "duration_seconds": round((datetime.utcnow() - start_time).total_seconds(), 2), + }, + } + + if results: + response["actions"] = create_clean_actions_response(results) + + log.info(f"\n\nRESPONSE: {response}\n\n") + + return response + + except Exception as e: + log.error(f"Error formatting execution response: {e}") + return { + "action_summary": { + "total_planned": len(planned_actions), + "completed": 0, + "failed": len(planned_actions), + "duration_seconds": round((datetime.utcnow() - start_time).total_seconds(), 2), + }, + "actions": [], + } + + +async def get_planned_actions_for_execution(message_id: UUID, chat_id: UUID, db: AsyncSession) -> List[Dict[str, Any]]: + """Retrieve all planned actions for a message that are ready for execution.""" + try: + # Get flow steps with planned actions - filter for TOOL steps that are planned + stmt = ( + select(MessageFlowStep) + .where(MessageFlowStep.message_id == message_id) # type: ignore[arg-type] + .where(MessageFlowStep.chat_id == chat_id) # type: ignore[arg-type] + .where(MessageFlowStep.is_planned == True) # type: ignore[arg-type] # noqa: E712 + .where(MessageFlowStep.is_executed == False) # type: ignore[arg-type] # noqa: E712 + .order_by(MessageFlowStep.step_order) # type: ignore[arg-type] + ) + result = await db.execute(stmt) + flow_steps = result.scalars().all() + + planned_actions = [] + for step in flow_steps: + execution_data = step.execution_data or {} + + planned_action = { + "step_id": str(step.id), + "step_order": step.step_order, + "tool_name": step.tool_name, + "args": execution_data.get("args", {}), + "action_summary": execution_data.get("action_summary", {}), + "tool_id": execution_data.get("tool_id"), + "artifact_id": execution_data.get("artifact_id"), # NEW: Include artifact ID + "sequence": step.step_order, # NEW: Include planned sequence to propagate + } + planned_actions.append(planned_action) + + return planned_actions + + except Exception as e: + log.error(f"Error retrieving planned actions: {e}") + return [] + + +async def get_original_user_query(message_id: UUID, db: AsyncSession) -> Optional[str]: + """Get the original user query for the message.""" + try: + stmt = select(Message).where(Message.id == message_id) # type: ignore[arg-type] + result = await db.execute(stmt) + message = result.scalar_one_or_none() + + if message: + return message.content + else: + log.warning(f"Message {message_id} not found") + return None + + except Exception as e: + log.error(f"Error retrieving original user query: {e}") + return None + + +def build_execution_prompt(original_query: str, planned_actions: List[Dict[str, Any]], conversation_context: Dict[str, Any]) -> str: + """Build the execution prompt for the LLM.""" + + # Format planned actions for the prompt + actions_text = "" + tool_categories = set() + for i, action in enumerate(planned_actions, 1): + action_summary = action.get("action_summary", {}) + tool_name = action.get("tool_name", "") + args = action.get("args", {}) + entity_type = action.get("entity_type", "") + if entity_type: + tool_categories.add(entity_type) + + # Create a readable description of the action + action_desc = action_summary.get("action", tool_name) + action_summary.get("details", "") + + actions_text += f"{i}. {action_desc}\n" + actions_text += f" Tool: {tool_name}\n" + actions_text += f" Arguments: {json.dumps(args, indent=4)}\n\n" + + # Format conversation context for the prompt + conversation_context_text = "" + for key, value in conversation_context.items(): + conversation_context_text += f"{key}: {value}\n" + + execution_prompt = f"""You are an API tool calling agent for Plane. You are executing approved actions for this user request: "{original_query}" + +context about Plane: +{plane_context} + +PLANNED ACTIONS (approved by user): +{actions_text} + +EXECUTION MODE: You must now EXECUTE these actions in the correct order using the available tools. + +EXECUTION GUIDELINES: +- Execute each planned action exactly once using the provided tool and arguments +- Use outputs from earlier actions as inputs to later actions when needed +- If an action creates an entity (like an issue, cycle, etc.), use the returned ID in subsequent actions that need it +- Execute actions in logical dependency order (e.g., create issue first, then add to cycle) +- If an action fails, stop execution and report the specific error +- Provide clear status updates after each successful action + +**CRITICAL: PLACEHOLDER HANDLING** +- Some arguments may contain placeholders that need to be resolved +- You should use the UUIDs, names, identifiers, etc. from the results returned by the previous actions to resolve the placeholders for the next action. +- Careful with the placeholder types. Use the tool description to determine the type of the placeholder, and then determine whether to use the UUID, name, identifier, etc. + +Entity Search Tools: +In addition to the modifying tools provided to you, you also have access to entity search tools (search_project_by_identifier, search_user_by_name, etc.). Use them ONLY if: +1. An argument is completely MISSING (e.g., no project_id at all) +2. An argument contains an EXPLICIT placeholder like: "" +3. An argument has a non-UUID value that needs resolution (e.g., project_id="PROJ") + +CRITICAL: You must use the EXACT tool names provided above. Do not modify, shorten, or change the tool names in any way. +EXAMPLE: If the planned action shows tool_name: "workitems_create", you must call "workitems_create" exactly, not "create_workitem" or "workitem_create". + +IMPORTANT: Start executing the actions now. Use the exact tool names and arguments provided in the planned actions above.""" # noqa: E501 + + return execution_prompt + + +def has_placeholders(tool_args: Dict[str, Any]) -> bool: + """Check if tool arguments contain placeholders or non-UUID *_id values that need resolution.""" + import re + + # Regex for matching standard UUIDs - fixed pattern to handle UUIDs properly + uuid_regex = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + # Include both "workitem" and "issue" as aliases for the same entity + entity_keys = {"module", "workitem", "issue", "project", "cycle", "label", "state", "user", "assignee"} + + for key, value in tool_args.items(): + # Special-case: workspace scope sentinel should NOT trigger resolution + if key == "project_id" and isinstance(value, str) and value == "__workspace_scope__": + continue + # 1) Explicit placeholder syntax + if isinstance(value, str) and " List[Dict[str, Any]]: + """Create clean action results without duplicate entity information. + + Filters out retrieval/search tools and only includes actual modifying actions. + """ + + clean_actions = [] + + log.info(f"\n\nEXECUTED ACTIONS in create_clean_actions_response: {executed_actions}\n\n") + + for action in executed_actions: + # Extract action from tool name (e.g., "workitems_create" -> "create") + tool_name = action.get("tool_name", "") + + # Filter out retrieval tools - only show actual actions to frontend + if tool_name: + is_retrieval, is_action = classify_tool(tool_name) + if is_retrieval and not is_action: + log.debug(f"Filtering out retrieval tool from actions response: {tool_name}") + continue + + action_type = TOOL_NAME_TO_CATEGORY_MAP.get(tool_name, {}).get("action_type", "") + + action_data = { + "action": action_type, + "artifact_type": action.get("artifact_type"), + "success": action.get("success"), + "executed_at": action.get("executed_at"), + "artifact_id": action.get("artifact_id"), # NEW: Include artifact ID + "sequence": action.get("sequence"), # NEW: Include planned step order + "version_number": action.get("version_number"), # NEW: Include version sequence number + } + + if action.get("success"): + # For successful actions, include essential entity info + entity_info = action.get("entity_info") + if entity_info and isinstance(entity_info, dict): + # Only include the most important entity fields + essential_entity = {} + for field in ["entity_url", "entity_name", "entity_type", "entity_id"]: + if field in entity_info and entity_info[field]: + essential_entity[field] = entity_info[field] + + # Include issue_identifier when available (for work-items) + if entity_info.get("issue_identifier"): + essential_entity["issue_identifier"] = entity_info["issue_identifier"] + + if essential_entity: + action_data["entity"] = essential_entity + + # Add project_identifier at the action root when derivable + project_identifier = None + try: + # Prefer deriving from identifiers present in entity_info + if entity_info.get("entity_type") == "project": + project_identifier = entity_info.get("entity_identifier") or entity_info.get("project_identifier") + elif entity_info.get("issue_identifier") and isinstance(entity_info.get("issue_identifier"), str): + ident = entity_info.get("issue_identifier") + if "-" in ident: + project_identifier = ident.split("-", 1)[0] + elif entity_info.get("entity_url") and isinstance(entity_info.get("entity_url"), str): + url = entity_info.get("entity_url") + # Attempt to parse /browse/PROJECT-SEQ/ pattern + if "/browse/" in url: + after = url.split("/browse/", 1)[1] + ident = after.split("/", 1)[0] + if "-" in ident: + project_identifier = ident.split("-", 1)[0] + except Exception: + project_identifier = None + + if project_identifier: + action_data["project_identifier"] = project_identifier + + # Extract the nice success message from the result + result = action.get("result", "") + if result: + # Extract the nice message that comes after "✅ " and before "\n\n" + nice_message = extract_success_message(result) + if nice_message: + action_data["message"] = nice_message + else: + # Fallback to generic messages + if "created" in result.lower(): + action_data["message"] = "Created successfully" + elif "updated" in result.lower(): + action_data["message"] = "Updated successfully" + else: + action_data["message"] = "Action completed successfully" + else: + # For failed actions, include error message + error = action.get("error", "") + if error: + action_data["error"] = error[:200] + "..." if len(error) > 200 else error + + clean_actions.append(action_data) + + return clean_actions + + +def extract_success_message(result: str) -> str: + """Extract the nice success message from the tool result.""" + if not result or not isinstance(result, str): + return "" + + # Look for the pattern: "✅ [message]\n\n" + if "✅" in result: + lines = result.split("\n") + for line in lines: + line = line.strip() + if line.startswith("✅"): + # Remove the "✅ " prefix and return the message + message = line[2:].strip() # Remove "✅ " (2 characters) + return message + + return "" diff --git a/apps/pi/pi/services/chat/helpers/action_executor.py b/apps/pi/pi/services/chat/helpers/action_executor.py deleted file mode 100644 index f1e4ca518b..0000000000 --- a/apps/pi/pi/services/chat/helpers/action_executor.py +++ /dev/null @@ -1,1876 +0,0 @@ -"""Action execution logic with retrieval tools.""" - -import ast -import contextlib -import datetime -import re -import uuid -from collections.abc import AsyncIterator -from typing import Any -from typing import Dict -from typing import List -from typing import Optional -from typing import Union - -from langchain_core.messages import HumanMessage -from langchain_core.messages import SystemMessage -from langchain_core.messages import ToolMessage -from langchain_core.prompts import ChatPromptTemplate - -from pi import logger -from pi import settings -from pi.app.models.enums import ExecutionStatus -from pi.app.models.enums import FlowStepType -from pi.app.models.enums import MessageMetaStepType -from pi.core.db.plane_pi.lifecycle import get_streaming_db_session -from pi.services.actions import MethodExecutor -from pi.services.actions import PlaneActionsExecutor -from pi.services.chat.prompts import action_category_router_prompt -from pi.services.chat.utils import get_current_timestamp_context -from pi.services.chat.utils import mask_uuids_in_text -from pi.services.chat.utils import standardize_flow_step_content -from pi.services.retrievers.pg_store.message import upsert_message_flow_steps as _upsert_message_flow_steps -from pi.services.schemas.chat import ActionCategoryRouting -from pi.services.schemas.chat import ActionCategorySelection - -from .action_summary_generator import ActionSummaryGenerator -from .tool_utils import build_method_prompt -from .tool_utils import classify_tool -from .tool_utils import clean_tool_args_for_storage -from .tool_utils import extract_action_type_from_tool_name -from .tool_utils import extract_entity_type_from_tool_name -from .tool_utils import format_tool_query_for_display -from .tool_utils import handle_missing_required_fields - -# from .tool_utils import log_toolset_details - -log = logger.getChild(__name__) - -MAX_ACTION_EXECUTOR_ITERATIONS = settings.chat.MAX_ACTION_EXECUTOR_ITERATIONS - - -def _format_tool_query_for_display(tool_name: str, tool_args: dict, user_query: str | None = None) -> str: - # Compatibility shim; delegate to shared helper - return format_tool_query_for_display(tool_name, tool_args, user_query) - - -def _clean_tool_args_for_storage(tool_args: Dict[str, Any]) -> Dict[str, Union[str, List[str], Any]]: - # Compatibility shim; delegate to shared helper - return clean_tool_args_for_storage(tool_args) - - -def _extract_entity_type_from_tool_name(tool_name: str) -> str: - return extract_entity_type_from_tool_name(tool_name) - - -def _extract_action_type_from_tool_name(tool_name: str) -> str: - return extract_action_type_from_tool_name(tool_name) - - -async def _enhance_action_summary_with_complete_data(action_summary: dict, tool_name: str, cleaned_args: dict) -> None: - """ - Enhance action summary with complete workitem data for update operations. - - Args: - action_summary: The action summary to enhance - tool_name: Name of the tool being executed - cleaned_args: Cleaned tool arguments - """ - try: - issue_id = cleaned_args.get("issue_id") or cleaned_args.get("workitem_id") - - # Handle case where issue_id might be a placeholder like "" - if not issue_id: - # Check if there's a placeholder format - for key, value in cleaned_args.items(): - if key in ["issue_id", "workitem_id"] and isinstance(value, str) and value.startswith(" None: - """Add workitem identifier to action summary parameters.""" - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact - - issue_details = await get_issue_identifier_for_artifact(str(issue_id)) - if not issue_details or not isinstance(issue_details, dict): - return - - # Ensure parameters structure exists - params = action_summary.get("parameters", {}) - if not isinstance(params, dict): - params = {} - action_summary["parameters"] = params - - # Add workitem identifier - identifier_value = issue_details.get("identifier") - if identifier_value: - workitem_block = params.setdefault("workitem", {}) - properties_block = workitem_block.setdefault("properties", {}) - properties_block["identifier"] = identifier_value - - # Add project identifier - project_identifier = issue_details.get("project_identifier") - if project_identifier: - project_block = params.setdefault("project", {}) - project_block["identifier"] = project_identifier - except Exception as e: - log.warning(f"Error adding workitem identifier: {e}") - - -async def _add_project_identifier(action_summary: dict, cleaned_args: dict) -> None: - """Add project identifier to action summary if available.""" - try: - params = action_summary.get("parameters") - if not isinstance(params, dict): - return - - # Skip if already has identifier - project_block = params.get("project") - if isinstance(project_block, dict) and project_block.get("identifier"): - return - - # Try to resolve from project_id (only when it's a valid UUID; skip placeholders/non-UUIDs) - project_id = cleaned_args.get("project_id") - if not project_id: - return - - import re - - uuid_pattern = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE) - if not (isinstance(project_id, str) and uuid_pattern.match(project_id)): - # Placeholder or non-UUID provided (e.g., "") - skip enrichment here - return - - from pi.app.api.v1.helpers.plane_sql_queries import get_project_details_for_artifact - - proj = await get_project_details_for_artifact(str(project_id)) - if proj and proj.get("identifier"): - if "project" not in params: - params["project"] = {} - params["project"]["identifier"] = proj["identifier"] - except Exception as e: - log.warning(f"Error adding project identifier: {e}") - - -async def execute_action_with_retrieval( - chatbot_instance, - selected_agents, - user_meta, - workspace_id, - workspace_slug, - project_id, - conversation_history, - enhanced_conversation_history, # 🆕 Enhanced context with action details - user_id, - chat_id, - query_flow_store, - combined_agent_query, - query_id, - step_order, - db, - reasoning_container=None, - is_project_chat=None, - pi_sidebar_open=None, - sidebar_open_url=None, -) -> AsyncIterator[str]: - """Execute action with access to retrieval tools""" - try: - # Resolve workspace_id from project_id if needed (for project-level chats) - # This must happen BEFORE Phase 1 tools are built - if not workspace_id and project_id: - try: - from pi.app.api.v1.helpers.plane_sql_queries import resolve_workspace_id_from_project_id - - resolved_workspace_id = await resolve_workspace_id_from_project_id(str(project_id)) - if resolved_workspace_id: - workspace_id = str(resolved_workspace_id) - log.info(f"ChatID: {chat_id} - Resolved workspace_id {workspace_id} from project_id {project_id} (Phase 1)") - except Exception as e: - log.error(f"ChatID: {chat_id} - Failed to resolve workspace_id from project_id {project_id}: {e}") - - clarification_requested = False - clarification_payload: dict | None = None - # ----- PHASE 1: Category Selection ----- - category_tools = await chatbot_instance._get_selected_tools( - db, - selected_agents, - user_meta, - workspace_id, - workspace_slug, - project_id, - user_id, - chat_id, - query_flow_store, - conversation_history, - query_id, - is_project_chat=is_project_chat, - pi_sidebar_open=pi_sidebar_open, - sidebar_open_url=sidebar_open_url, - ) - # If only the OAuth consent tool is returned, invoke it directly - if len(category_tools) == 1 and getattr(category_tools[0], "name", "") == "oauth_authorization_required": - auth_tool = category_tools[0] - auth_message = await auth_tool.ainvoke(combined_agent_query) - - # Just show OAuth message and return - let frontend handle the flow - # Note: We don't store the OAuth message in the database since it's not part of the conversation history - - # Create a QUEUE flow step for the OAuth message to maintain the flow tracking - # This ensures the stream-answer endpoint can find the proper flow step - # Store complete ChatRequest-like data so stream-answer can process it directly - async with get_streaming_db_session() as _subdb: - flow_step_result = await _upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=[ - { - "step_order": step_order, - "step_type": FlowStepType.TOOL.value, - "tool_name": "QUEUE", # Use QUEUE so stream-answer can find it - "content": "OAuth authorization required", - "execution_data": { - "query": combined_agent_query, - "chat_id": str(chat_id), - "workspace_id": str(workspace_id) if workspace_id else "", - "workspace_slug": workspace_slug or "", - "project_id": str(project_id) if project_id else "", - "user_id": str(user_id), - "is_new": False, # Not used anymore - using sequence-based check instead - "is_temp": False, - "workspace_in_context": True, - "is_project_chat": bool(project_id), - "enhanced_conversation_history": enhanced_conversation_history, - "llm": chatbot_instance.switch_llm - if hasattr(chatbot_instance, "switch_llm") - else "gpt-4.1", # Use chatbot's LLM model - "context": user_meta or {"first_name": "", "last_name": ""}, - }, - "oauth_required": True, # Use dedicated OAuth column - "is_planned": False, - "is_executed": False, - "execution_success": ExecutionStatus.PENDING, # OAuth flow is pending - } - ], - db=_subdb, - ) - - if flow_step_result.get("message") != "success": - log.warning(f"Failed to create OAuth flow step: {flow_step_result}") - - yield auth_message - return - - if not category_tools: - log.warning("Unable to initialize action tools. Please try again.") - msg = "An unexpected error occurred. Please try again later." - yield msg - yield f"__FINAL_RESPONSE__{msg}" - return - - current_step = step_order - tool_flow_steps = [] - # Always initialize clarification context holder to avoid unbound variable - clar_ctx: Dict[str, Any] = {} - # Check if we are resuming after a clarification response; if so, skip category selection - skip_category_selection = False - selections_list: List[Union[ActionCategorySelection, Dict[str, Optional[str]]]] = [] - try: - if isinstance(user_meta, dict) and user_meta.get("clarification_context"): - clar_ctx = user_meta.get("clarification_context") or {} - hint_list = clar_ctx.get("category_hints") or [] - - answer_text = clar_ctx.get("answer_text") - - # Always try to recover previously selected categories from the saved clarification step - prev_categories: List[str] = [] - try: - from pi.services.retrievers.pg_store.message import get_tool_results_from_chat_history as _get_steps - - clar_steps = await _get_steps(db=db, chat_id=uuid.UUID(str(chat_id)), tool_name="ask_for_clarification") - if clar_steps: - last = clar_steps[0] - exec_data = getattr(last, "execution_data", {}) or {} - prev_categories = [str(c) for c in (exec_data.get("selected_categories") or []) if c] - except Exception: - prev_categories = [] - - # Union of hints and previously selected categories - union_categories = list({*(str(h) for h in hint_list if h), *prev_categories}) - if union_categories: - skip_category_selection = True - selections_list = [{"category": c, "rationale": "from_clarification_resume"} for c in union_categories] - except Exception: - skip_category_selection = False - - if not skip_category_selection: - # Directly call get_available_plane_actions tool without LLM - category_tool = next((t for t in category_tools if hasattr(t, "name") and t.name == "get_available_plane_actions"), None) - if not category_tool: - available_tool_names = [t.name if hasattr(t, "name") else "unnamed" for t in category_tools] - log.warning(f"Category selection tool not found. Available tools: {available_tool_names}") - msg = "An unexpected error occurred. Please try again later." - yield msg - yield f"__FINAL_RESPONSE__{msg}" - return - - # Execute category selection tool (advisory details like methods) - try: - # Directly invoke the tool with the user's intent - advisory_text = await category_tool.ainvoke({"user_intent": combined_agent_query}) - - # Track advisory tool selection - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL, - "tool_name": "get_available_plane_actions", - "content": standardize_flow_step_content(advisory_text, FlowStepType.TOOL), - "execution_data": {"args": {"user_intent": combined_agent_query}}, - }) - current_step += 1 - except Exception as e: - log.warning(f"Category selection failed: {str(e)}") - msg = "An unexpected error occurred. Please try again later." - yield msg - yield f"__FINAL_RESPONSE__{msg}" - return - - # Run LLM category router (multi-select) using advisory + user intent - router_prompt_template = ChatPromptTemplate.from_messages([ - ("system", action_category_router_prompt), - ("human", "{custom_prompt}"), - ]) - custom_prompt = f"User intent: {combined_agent_query}\n\n" f"Advisory: {advisory_text}" - # Prepend enhanced conversation history for better routing context if available - if enhanced_conversation_history and isinstance(enhanced_conversation_history, str) and enhanced_conversation_history.strip(): - custom_prompt = f"CONVERSATION HISTORY & ACTION CONTEXT:\n{enhanced_conversation_history}\n\n" + custom_prompt - - # Request raw response for debugging - action_router = chatbot_instance.decomposer_llm.with_structured_output( - ActionCategoryRouting, - include_raw=True, - method="json_mode", - ) - action_router.set_tracking_context(query_id, db, MessageMetaStepType.ACTION_CATEGORY_ROUTING, chat_id=str(chat_id)) - dynamic_action_router = router_prompt_template | action_router - - import time - - try: - category_router_start = time.time() - log.info(f"ChatID: {chat_id} - Starting action category router LLM call") - routing = await dynamic_action_router.ainvoke({"custom_prompt": custom_prompt}) - category_router_elapsed = time.time() - category_router_start - log.info(f"ChatID: {chat_id} - Action category router LLM call completed in {category_router_elapsed:.2f}s") - - # When include_raw=True, routing is a dict with keys: raw, parsed, parsing_error - parsed_obj = routing - if isinstance(routing, dict): - parsed_obj = routing.get("parsed") - # with contextlib.suppress(Exception): - # log.debug(f"ChatID: {chat_id} - Router raw message: {routing.get("raw")}") - # log.debug(f"ChatID: {chat_id} - Router parsing_error: {routing.get("parsing_error")}") - - if parsed_obj and getattr(parsed_obj, "selections", None): - selections_list = list(parsed_obj.selections) - - # Log router response for analysis - with contextlib.suppress(Exception): - log.info(f"ChatID: {chat_id} - Action Category Router response selections: {selections_list}") - # log.debug(f"ChatID: {chat_id} - Raw router response: {routing}") - except Exception as e: - log.warning(f"Action category router failed: {e}") - - # If router returns empty list, it means the request is unsupported - if not selections_list: - log.info(f"ChatID: {chat_id} - Category router returned empty list - unsupported action request") - - # Provide user-friendly rejection message - rejection_message = ( - "I understand you'd like to perform this action, but I'm unable to fulfill this request with the available tools.\n\n" - "**I can help you with:** work-items (create, update, assign, move), projects (create, list, manage), " - "cycles (create, manage, add work-items), modules (create, manage, add work-items), " - "labels and states (create, update), create pages\n\n" - "Feel free to ask me about any of these supported actions, or let me know if you'd like help with something else!" - ) - - yield rejection_message - yield f"__FINAL_RESPONSE__{rejection_message}" - return - - # Persist router decision as a routing flow step - # Normalize selections into serialisable dicts for persistence - routing_content: List[Dict[str, Optional[str]]] = [] - for sel in selections_list: - if isinstance(sel, ActionCategorySelection): - routing_content.append({ - "category": sel.category, - "rationale": sel.rationale, - }) - elif isinstance(sel, dict): - routing_content.append({ - "category": sel.get("category"), - "rationale": sel.get("rationale"), - }) - - async with get_streaming_db_session() as _subdb: - flow_step_result = await _upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=[ - { - "step_order": current_step, - "step_type": FlowStepType.ROUTING.value, - "tool_name": "action_category_router", - "content": standardize_flow_step_content(routing_content, FlowStepType.ROUTING), - "execution_data": {"skip_category_selection": False, "enhanced_conversation_history": enhanced_conversation_history}, - } - ], - db=_subdb, - ) - if flow_step_result["message"] != "success": - log.warning("Failed to record category routing in database") - current_step += 1 - else: - # Record that we skipped category selection due to clarification context - async with get_streaming_db_session() as _subdb: - flow_step_result = await _upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=[ - { - "step_order": current_step, - "step_type": FlowStepType.ROUTING.value, - "tool_name": "action_category_router", - "content": standardize_flow_step_content( - [ - {"category": str(sel.get("category")) if isinstance(sel, dict) else str(getattr(sel, "category", ""))} - for sel in selections_list - ], - FlowStepType.ROUTING, - ), - "execution_data": { - "skip_category_selection": True, - "source": "clarification", - "enhanced_conversation_history": enhanced_conversation_history, - }, - } - ], - db=_subdb, - ) - if flow_step_result.get("message") != "success": - log.warning("Failed to record clarification-based routing in database") - current_step += 1 - - # ----- PHASE 2: Method Selection & Execution ----- - # Build tools across all selected categories - - # Get OAuth token for method executor (reuse from phase 1 - already validated) - access_token = await chatbot_instance._get_oauth_token_for_user(db, user_id, workspace_id) - - # Build workspace context - from pi.app.api.v1.helpers.plane_sql_queries import get_workspace_slug - - workspace_slug = await get_workspace_slug(workspace_id) - - # Normalize project_id: if a non-UUID value (e.g., project identifier like 'OGX') was - # passed from the client context, resolve it to the actual project UUID so that - # downstream entity search tools don't receive an invalid UUID. - try: - proj_str = str(project_id) if project_id else None - is_uuid_like = False - if proj_str: - try: - uuid.UUID(proj_str) - is_uuid_like = True - except Exception: - is_uuid_like = False - - if proj_str and not is_uuid_like: - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_project_id_from_identifier - - resolved = await get_project_id_from_identifier(proj_str, str(workspace_id)) - if resolved: - project_id = str(resolved) - else: - # If unresolved, drop project scope to avoid passing an invalid UUID - log.warning( - f"ChatID: {chat_id} - Received non-UUID project_id '{proj_str}'. Could not resolve identifier to UUID; omitting project scope from context." # noqa: E501 - ) - project_id = None - except Exception as _e: - log.warning( - f"ChatID: {chat_id} - Failed to resolve project identifier '{proj_str}' to UUID: {_e}. Omitting project scope from context." # noqa: E501 - ) - project_id = None - except Exception: - # Defensive: never block the flow on normalization errors - pass - - context = { - "workspace_id": workspace_id, - "workspace_slug": workspace_slug, - "project_id": project_id, - "user_id": user_id, - "conversation_history": conversation_history, - "user_meta": user_meta, - "is_project_chat": is_project_chat, - } - - # Create method executor - if access_token and access_token.startswith("plane_api_"): - actions_executor = PlaneActionsExecutor(api_key=access_token, base_url=settings.plane_api.HOST) - else: - actions_executor = PlaneActionsExecutor(access_token=access_token, base_url=settings.plane_api.HOST) - - method_executor = MethodExecutor(actions_executor) - - # Build method-specific tools for all selected categories - all_method_tools = [] - built_categories = [] - for sel in selections_list: - # Extract category from either pydantic object or dict - cat: Optional[str] - if isinstance(sel, ActionCategorySelection): - cat = sel.category - else: # isinstance(sel, dict) - cat = sel.get("category") - - if not cat or cat in built_categories: - continue - built_categories.append(cat) - try: - # During planning, augment category tools with category-scoped entity-search tools - tools_for_cat = chatbot_instance._build_planning_method_tools(cat, method_executor, context) - all_method_tools.extend(tools_for_cat) - except Exception as e: - log.warning(f"Failed to build tools for category {cat}: {e}") - - # Deduplicate method tools (entity search tools may appear in multiple categories) - seen_tool_names = set() - deduplicated_method_tools = [] - for tool in all_method_tools: - if tool.name not in seen_tool_names: - deduplicated_method_tools.append(tool) - seen_tool_names.add(tool.name) - - # Include retrieval tools from phase-1 so the LLM can chain look-ups ↔ actions - # Filter out the category selection tool and include all other tools - # Phase-2: rebuild retrieval tools with the normalized project_id to ensure correct scoping - fresh_retrieval_tools = chatbot_instance._create_tools( - db, - user_meta, - workspace_id, - project_id, - user_id, - chat_id, - query_flow_store, - conversation_history, - query_id, - is_project_chat=is_project_chat, - ) - # Merge while preserving order and avoiding duplicates - method_tool_names = {t.name for t in deduplicated_method_tools} - combined_tools = deduplicated_method_tools + [t for t in fresh_retrieval_tools if t.name not in method_tool_names] - if not combined_tools: - log.warning("No method or retrieval tools available for selected categories") - msg = "An unexpected error occurred. Please try again later." - yield msg - yield f"__FINAL_RESPONSE__{msg}" - return - - # Build the planning prompt via shared helper, pass previously derived clar_ctx - method_prompt = build_method_prompt( - combined_agent_query, - project_id, - user_id, - workspace_id, - enhanced_conversation_history, - clarification_context=clar_ctx, - ) - - date_time_context = await get_current_timestamp_context(user_id) - method_prompt = f"{method_prompt}\n\n{date_time_context}" - - # Record the tool orchestration context (enhanced conversation history) before planning - try: - if enhanced_conversation_history and isinstance(enhanced_conversation_history, str) and enhanced_conversation_history.strip(): - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL.value, - "tool_name": "tool_orchestration_context", - "content": "Context used for method planning", - "execution_data": { - "enhanced_conversation_history": enhanced_conversation_history, - "agent_query": combined_agent_query, - "selected_categories": list(built_categories), - "available_tools_count": len(combined_tools), - "method_tool_names": [t.name for t in all_method_tools], - "bound_tool_names": [t.name for t in combined_tools], - }, - "is_planned": False, - "is_executed": False, - "execution_success": ExecutionStatus.PENDING, - }) - current_step += 1 - except Exception: - pass - - # Log the full method planning prompt and context for debugging - try: - # Determine if this is a clarification follow-up (raw user input) or router-synthesized query - is_clarification_followup = bool(user_meta and isinstance(user_meta, dict) and user_meta.get("clarification_context")) - query_label = "Agent Query (raw user input from clarification)" if is_clarification_followup else "Agent Query (router-synthesized)" - - # Log comprehensive debugging information - log.info(f"ChatID: {chat_id} - {query_label}: {combined_agent_query}") - log.info(f"ChatID: {chat_id} - Selected Categories: {built_categories}") - log.info(f"ChatID: {chat_id} - Available Tools Count: {len(combined_tools)}") - try: - tool_names_for_log = [t.name for t in combined_tools] - log.info(f"ChatID: {chat_id} - Planning tools: {tool_names_for_log}") - except Exception: - pass - - except Exception as e: - log.warning(f"ChatID: {chat_id} - Failed to log debug info: {e}") - - # Initialize messages for Phase 2 tool orchestration - messages = [SystemMessage(content=method_prompt), HumanMessage(content=combined_agent_query)] - - # Log tools in readable format with argument schema details - # log_toolset_details(combined_tools, chat_id) - # Re-bind LLM with the full toolset (action methods + retrieval) - llm_with_method_tools = chatbot_instance.tool_llm.bind_tools(combined_tools) - - # Set tracking context for method planning LLM calls - llm_with_method_tools.set_tracking_context(query_id, db, MessageMetaStepType.ACTION_METHOD_PLANNING, chat_id=str(chat_id)) - - # Continue iterative tool calling with method tools - response = await llm_with_method_tools.ainvoke(messages) - messages.append(response) - - # Log LLM's initial response for debugging - log.info(f"ChatID: {chat_id} - LLM INITIAL RESPONSE") - log.info(f"ChatID: {chat_id} - Has Tool Calls: {hasattr(response, "tool_calls") and bool(getattr(response, "tool_calls", None))}") - - # Log the complete LLM response for debugging - try: - response_content = str(getattr(response, "content", "") or "").strip() - tool_calls = getattr(response, "tool_calls", None) - - log.info(f"ChatID: {chat_id} - LLM Response Content: {response_content}") - # if tool_calls: - # log.info(f"ChatID: {chat_id} - LLM Tool Calls: {tool_calls}") - # else: - # log.info(f"ChatID: {chat_id} - LLM Tool Calls: None") - - # Log the full response object for complete debugging - try: - import json - - response_dict = { - "content": response_content, - "tool_calls": tool_calls, - "response_metadata": getattr(response, "response_metadata", None), - "id": getattr(response, "id", None), - "usage_metadata": getattr(response, "usage_metadata", None), - } - log.info(f"ChatID: {chat_id} - Full LLM Response: {json.dumps(response_dict, default=str, indent=2)}") - except Exception: - log.info(f"ChatID: {chat_id} - Full LLM Response (raw): {response}") - - except Exception as e: - log.warning(f"ChatID: {chat_id} - Failed to log LLM response: {e}") - - # Log planner decisions immediately (before flow step creation) - try: - if hasattr(response, "tool_calls") and getattr(response, "tool_calls", None): - tool_calls_list = getattr(response, "tool_calls", []) or [] - tool_names = [] - for _tc in tool_calls_list: - if isinstance(_tc, dict): - tool_names.append(_tc.get("name", "")) - else: - tool_names.append(getattr(_tc, "name", "")) - - reasoning_text = str(getattr(response, "content", "") or "").strip() - reason_preview = reasoning_text or "(none)" - - log.info(f"ChatID: {chat_id} - Planner selected tools: {tool_names}") - log.info(f"ChatID: {chat_id} - Planner reasoning: {reason_preview}") - else: - # Log when no tool calls are made - reasoning_text = str(getattr(response, "content", "") or "").strip() - log.info(f"ChatID: {chat_id} - Planner selected tools: []") - log.info(f"ChatID: {chat_id} - Planner reasoning (no tools): {reasoning_text}") - except Exception as e: - log.warning(f"ChatID: {chat_id} - Failed to log planner decisions: {e}") - - # Record planner decision for this response (selected tools + reasoning) - try: - if hasattr(response, "tool_calls") and getattr(response, "tool_calls", None): - selected_calls: list[dict] = [] - for _tc in getattr(response, "tool_calls", []) or []: - if isinstance(_tc, dict): - _name = _tc.get("name", "") - _args = _tc.get("args", {}) - _id = _tc.get("id", "") - else: - _name = getattr(_tc, "name", "") - _args = getattr(_tc, "args", {}) - _id = getattr(_tc, "id", "") - selected_calls.append({"name": _name, "args": _args, "id": _id}) - - reasoning_text = str(getattr(response, "content", "") or "").strip() - - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL, - "tool_name": "planner_tool_selection", - "content": standardize_flow_step_content({"selected_tool_calls": selected_calls, "reasoning": reasoning_text}, FlowStepType.TOOL), - "execution_data": { - "selected_tool_calls": selected_calls, - "reasoning": reasoning_text, - }, - "is_planned": False, - "is_executed": False, - "execution_success": ExecutionStatus.PENDING, - }) - current_step += 1 - except Exception: - pass - # Persist whether the initial LLM response had tool calls - try: - for _s in tool_flow_steps: - if _s.get("tool_name") == "tool_orchestration_context": - _ed = _s.setdefault("execution_data", {}) - _ed["initial_has_tool_calls"] = bool(getattr(response, "tool_calls", None)) - break - except Exception: - pass - # Handle iterative method tool calling until completion - planned_actions: list = [] - planned_action_keys: set[str] = set() - max_iterations = MAX_ACTION_EXECUTOR_ITERATIONS # Safety limit to prevent infinite loops - iteration_count = 0 - reminder_count = 0 - max_reminders = 3 # Limit consecutive reminders to prevent infinite loops - loop_warning_detected = False - - # Continue the loop until either: - # 1. No more tool calls are returned by the LLM, OR - # 2. At least one action has been planned (to handle simple requests) - # 3. Maximum iterations reached (safety check) - loop_condition = ( - (hasattr(response, "tool_calls") and getattr(response, "tool_calls", None)) or len(planned_actions) == 0 - ) and iteration_count < max_iterations - - # Track tool calls to detect loops - tool_call_history = [] - - while loop_condition: - iteration_count += 1 - tool_messages = [] - - # If no tool calls in this response but we haven't planned any actions yet, - # we need to continue to allow the LLM to plan at least one action - if not (hasattr(response, "tool_calls") and getattr(response, "tool_calls", None)): - # First, check if LLM explicitly returned NO_ACTIONS_PLANNED - if so, respect that decision - if hasattr(response, "content") and response.content: - content = str(response.content).strip() - if content == "NO_ACTIONS_PLANNED": - log.info(f"ChatID: {chat_id} - LLM returned NO_ACTIONS_PLANNED - stopping iteration without reminders") - break - - # Check if we've exceeded reminder limit - if reminder_count >= max_reminders: - log.warning(f"ChatID: {chat_id} - Maximum reminders ({max_reminders}) reached. Breaking loop to prevent infinite reminders.") - break - - reminder_count += 1 - log.info(f"Sending REMINDER {reminder_count}/{max_reminders} to LLM to plan modifying actions") - # Add a reminder message to help the LLM understand it needs to plan actions - reminder_message = SystemMessage( - content="REMINDER: You have only used retrieval/search tools so far. The user's request requires MODIFYING data (move/add/create/update/delete). You MUST now plan the actual MODIFYING ACTION using the appropriate tool." # noqa: E501 - ) - messages.append(reminder_message) - # Get next response from LLM to continue planning - response = await llm_with_method_tools.ainvoke(messages) - messages.append(response) - continue - else: - # Reset reminder count when LLM provides tool calls - reminder_count = 0 - - # Execute all tool calls in this round - log.info(f"ChatID: {chat_id} - Entering the for loop to execute selected tool calls in this iteration: {iteration_count}") - for tool_call in getattr(response, "tool_calls", []): - # Handle both dictionary and object access patterns for LangChain tool calls - if isinstance(tool_call, dict): - tool_name = tool_call.get("name", "") - tool_args = tool_call.get("args", {}) - tool_id = tool_call.get("id", "") - else: - # Handle object access pattern - tool_name = getattr(tool_call, "name", "") - tool_args = getattr(tool_call, "args", {}) - tool_id = getattr(tool_call, "id", "") - - # Additional validation: ensure we have a valid tool name - if not tool_name or not isinstance(tool_name, str): - log.warning(f"Invalid tool name: {tool_name}, skipping tool call") - continue - - # Log each tool call execution for debugging - log.info(f"ChatID: {chat_id} - EXECUTING TOOL: {tool_name} with args: {tool_args}") - - # Track tool calls to detect loops - tool_call_signature = f"{tool_name}({tool_args})" - tool_call_history.append(tool_call_signature) - - # Check for repeated identical calls (potential loop) - if tool_call_history.count(tool_call_signature) > 2: - log.warning( - f"ChatID: {chat_id} - DETECTED LOOP: Tool {tool_name} called {tool_call_history.count(tool_call_signature)} times with same args: {tool_args}" # noqa: E501 - ) # noqa: E501 - log.warning(f"ChatID: {chat_id} - Tool call history: {tool_call_history[-10:]}") # Last 10 calls - loop_warning_detected = True - - # Special handling for no_actions_planned tool - if tool_name == "no_actions_planned": - # Execute the tool to get the structured payload - tool_func = next((t for t in combined_tools if t.name == tool_name), None) - result = None - try: - if tool_func is not None: - if hasattr(tool_func, "ainvoke"): - result = await tool_func.ainvoke(tool_args) - else: - result = tool_func.invoke(tool_args) - else: - result = '{"reason": "Tool not found", "explanation": "", "token": "NO_ACTIONS_PLANNED"}' - except Exception as e: - log.warning(f"No actions planned tool execution failed: {e}") - result = '{"reason": "Tool execution failed", "explanation": "", "token": "NO_ACTIONS_PLANNED"}' - - # Parse JSON payload best-effort - try: - no_actions_payload = json.loads(str(result)) if result else {} - except Exception: - no_actions_payload = {"reason": "Parse failed", "explanation": str(result), "token": "NO_ACTIONS_PLANNED"} - - # Log the no actions decision - try: - reason = no_actions_payload.get("reason", "Unknown") - explanation = no_actions_payload.get("explanation", "") - log.info(f"ChatID: {chat_id} - LLM decided NO_ACTIONS_PLANNED: {reason}") - if explanation: - log.info(f"ChatID: {chat_id} - No actions explanation: {explanation}") - except Exception: - pass - - # Add tool message for conversation continuity - tool_message = ToolMessage(content=str(result), tool_call_id=tool_id) - tool_messages.append(tool_message) - - # Track flow step for no actions decision - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL, - "tool_name": "no_actions_planned", - "content": standardize_flow_step_content(no_actions_payload, FlowStepType.TOOL), - "execution_data": { - "args": tool_args, - "no_actions_payload": no_actions_payload, - "original_query": combined_agent_query, - }, - "is_planned": False, - "is_executed": False, - "execution_success": ExecutionStatus.SUCCESS, - }) - current_step += 1 - - # Set the response content to the token for downstream detection - response.content = "NO_ACTIONS_PLANNED" - - # Clear further tool calls and break - response.tool_calls = [] if hasattr(response, "tool_calls") else None - break - - # Special handling for clarification tool - elif tool_name == "ask_for_clarification": - # Execute the tool to get the structured payload - tool_func = next((t for t in combined_tools if t.name == tool_name), None) - result = None - try: - if tool_func is not None: - if hasattr(tool_func, "ainvoke"): - result = await tool_func.ainvoke(tool_args) - else: - result = tool_func.invoke(tool_args) - else: - result = "{}" - except Exception as e: - log.warning(f"Clarification tool execution failed: {e}") - result = "{}" - - # Parse JSON payload best-effort - try: - clarification_payload = json.loads(str(result)) if result else {} - except Exception: - clarification_payload = {"raw": str(result)} - - # Log clarification payload produced by LLM tool call - # try: - # import json as _json - - # log.info( - # f"{"*" * 100}\nChatID: {chat_id} - ASK_FOR_CLARIFICATION payload (LLM): {_json.dumps(clarification_payload, default=str)}\n{"*" * 100}" # noqa: E501 - # ) - # except Exception: - # pass - - # Add tool message for conversation continuity - tool_message = ToolMessage(content=str(result), tool_call_id=tool_id) - tool_messages.append(tool_message) - - # Track flow step for clarification (audit only) - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL, - "tool_name": "ask_for_clarification", - "content": standardize_flow_step_content(clarification_payload, FlowStepType.TOOL), - "execution_data": { - "args": tool_args, - "clarification_payload": clarification_payload, - # Persist context for audit purposes only; the control flow relies on message_clarifications table - "selected_categories": list(built_categories) if "built_categories" in locals() else [], - "method_tool_names": [t.name for t in all_method_tools] if "all_method_tools" in locals() else [], - "bound_tool_names": [t.name for t in combined_tools] if "combined_tools" in locals() else [], - "original_query": combined_agent_query, - }, - "is_planned": False, - "is_executed": False, - "execution_success": ExecutionStatus.PENDING, - }) - current_step += 1 - - # Stream a dedicated clarification event block for the frontend to render a prompt UI - try: - yield f"πspecial clarification blockπ: {json.dumps(clarification_payload)}\n" - except Exception: - yield f"πspecial clarification blockπ: {str(result)}\n" - - # Persist a message_clarifications row for deterministic follow-up handling - try: - from uuid import UUID - - from pi.services.retrievers.pg_store.clarifications import create_clarification - - clar_kind = "action" if ("all_method_tools" in locals() and all_method_tools) else "retrieval" - method_names = [t.name for t in all_method_tools] if "all_method_tools" in locals() else [] - bound_names = [t.name for t in combined_tools] if "combined_tools" in locals() else [] - categories = list(built_categories) if "built_categories" in locals() else [] - - log.info( - f"ChatID: {chat_id} - Creating clarification record: kind={clar_kind}, message_id={query_id}, categories={categories}" # noqa: E501 - ) - - # Use the current user message id (query_id) as the owning message for the clarification record. - # The assistant clarification message id is not available in this scope. - clar_id = await create_clarification( - db, - chat_id=UUID(str(chat_id)), - message_id=UUID(str(query_id)), - kind=clar_kind, - original_query=combined_agent_query, - payload=clarification_payload or {}, - categories=[str(c) for c in categories], - method_tool_names=method_names, - bound_tool_names=bound_names, - ) - log.info(f"ChatID: {chat_id} - Successfully created clarification record: id={clar_id}") - except Exception as e: - log.warning(f"ChatID: {chat_id} - Failed to create clarification record: {e}") - - clarification_requested = True - # Break the loop immediately; we'll persist steps below and end the stream - # Clear tool_calls to exit safely after loop - response.tool_calls = [] if hasattr(response, "tool_calls") else None - break - - # Check if this is an action tool (not a retrieval tool) - # - # Tool Classification Logic: - # 1. Standard retrieval tools: vector_search_tool, structured_db_tool, etc. - # 2. Read-only operations: tools with patterns like _list, _retrieve, _get, _search - # 3. Modifying operations: tools with patterns like _create, _update, _delete, _add, _remove, _archive, _unarchive - # 4. Safety rule: if a tool has both read-only and modifying patterns, treat as modifying (safer) - # - # This ensures that operations like 'list_modules' don't require user approval, - # while operations like 'create_workitem' still do. - - # Classify tool via shared helper - _is_retrieval_tool, is_action_tool = classify_tool(tool_name) - - if is_action_tool: - # Convert tool call to the format expected by ActionSummaryGenerator - tool_call_dict = {"name": tool_name, "args": tool_args, "id": tool_id} - - # Build context for entity resolution - action_context = { - "workspace_slug": workspace_slug, - "project_id": project_id, - "workspace_id": workspace_id, - } - - # Preflight: ensure required fields exist before planning actions - try: - from .tool_utils import preflight_missing_required_fields - - missing_required = preflight_missing_required_fields(tool_name, tool_args, action_context) - except Exception: - missing_required = [] - - # Attempt auto-resolution of common placeholders before asking for clarification - try: - - def _extract_placeholder_name(val: str) -> tuple[str | None, str | None]: - if isinstance(val, str) and ""): - try: - inner = val.strip("<>") # id of project: Mattu - parts = inner.split(":", 1) - left = parts[0].strip() # id of project - right = parts[1].strip() # Mattu - # entity type is last token of left - etype = left.split()[-1].strip() - return etype, right - except Exception: - return None, None - return None, None - - async def _auto_resolve_project_id_if_needed() -> bool: - nonlocal tool_args - # Only for project-scoped actions - pid_val = tool_args.get("project_id") if isinstance(tool_args, dict) else None - etype, pname = _extract_placeholder_name(pid_val) if isinstance(pid_val, str) else (None, None) - if pname and (etype == "project" or etype is None): - # Find search_project_by_name tool - resolver = next((t for t in combined_tools if getattr(t, "name", "") == "search_project_by_name"), None) - if resolver: - try: - res = await resolver.ainvoke({"name": pname, "workspace_slug": workspace_slug}) - # Parse first id from Result: {...} - m = re.search(r"\n\nResult:\s*(\{[\s\S]*?\})", str(res)) - if m: - try: - parsed = ast.literal_eval(m.group(1)) - if isinstance(parsed, dict): - cand = parsed.get("id") or parsed.get("project_id") - if isinstance(cand, str): - tool_args["project_id"] = cand - return True - except Exception: - pass - except Exception: - return False - # If not placeholder but a non-UUID string, try extracting a quoted name from the value - if isinstance(pid_val, str): - # Detect UUID quickly; if it's a UUID, skip - uuid_like = re.match(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", pid_val, flags=re.IGNORECASE) - if not uuid_like: - # Try to extract 'Name' or "Name" from free text - m_quote = re.search(r"'([^']+)'|\"([^\"]+)\"", pid_val) - candidate = None - if m_quote: - candidate = m_quote.group(1) or m_quote.group(2) - # As last resort, if string is short (<80 chars) and no quotes, use whole string - if not candidate and len(pid_val) <= 80: - candidate = pid_val.strip() - if candidate: - resolver = next((t for t in combined_tools if getattr(t, "name", "") == "search_project_by_name"), None) - if resolver: - try: - res = await resolver.ainvoke({"name": candidate, "workspace_slug": workspace_slug}) - m = re.search(r"\n\nResult:\s*(\{[\s\S]*?\})", str(res)) - if m: - try: - parsed = ast.literal_eval(m.group(1)) - if isinstance(parsed, dict): - cand = parsed.get("id") or parsed.get("project_id") - if isinstance(cand, str): - tool_args["project_id"] = cand - return True - except Exception: - pass - except Exception: - return False - return False - - # If missing project_id (or it is a placeholder/non-uuid), try auto-resolve - if "project_id" in (missing_required or []): - did_resolve = await _auto_resolve_project_id_if_needed() - if not did_resolve and tool_name == "workitems_update": - # Derive project_id from issue_id when updating a work-item - try: - issue_id_val = tool_args.get("issue_id") if isinstance(tool_args, dict) else None - if isinstance(issue_id_val, str) and re.match( - r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", issue_id_val, flags=re.IGNORECASE - ): - from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact - - ident_details = await get_issue_identifier_for_artifact(issue_id_val) - if ident_details and isinstance(ident_details, dict): - pid = ident_details.get("project_id") - if isinstance(pid, str): - tool_args["project_id"] = pid - did_resolve = True - except Exception: - pass - - if did_resolve: - # Recompute missing fields - try: - from .tool_utils import preflight_missing_required_fields as _pre - - missing_required = _pre(tool_name, tool_args, action_context) - except Exception: - pass - except Exception: - pass - - if missing_required: - clarification_result = await handle_missing_required_fields( - tool_name=tool_name, - tool_args=tool_args, - action_context=action_context, - missing_required=missing_required, - method_executor=method_executor, - workspace_slug=workspace_slug, - chat_id=chat_id, - tool_id=tool_id, - current_step=current_step, - combined_agent_query=combined_agent_query, - is_project_chat=is_project_chat, - ) - - if clarification_result: - clarification_payload = clarification_result["clarification_payload"] - clarification_tool_message: Optional[ToolMessage] = clarification_result.get("tool_message") - flow_step = clarification_result["flow_step"] - - # Add tool message to conversation - if clarification_tool_message is not None: - tool_messages.append(clarification_tool_message) - - # Track flow step for clarification - tool_flow_steps.append(flow_step) - current_step += 1 - - # Mark and stream clarification; stop orchestration early - clarification_requested = True - try: - yield f"πspecial clarification blockπ: {json.dumps(clarification_payload)}\n" - except Exception: - yield f"πspecial clarification blockπ: {str(clarification_payload)}\n" - - # Persist a message_clarifications row for deterministic follow-up handling - try: - from pi.services.retrievers.pg_store.clarifications import create_clarification - - clar_kind = "action" # Preflight clarifications are always for actions - method_names = [t.name for t in all_method_tools] if "all_method_tools" in locals() else [] - bound_names = [t.name for t in combined_tools] if "combined_tools" in locals() else [] - categories = list(built_categories) if "built_categories" in locals() else [] - - log.info( - f"ChatID: {chat_id} - Creating clarification record (preflight): kind={clar_kind}, message_id={query_id}, categories={categories}" # noqa: E501 - ) - - # Use the current user message id (query_id) as the owning message for the clarification record. - clar_id = await create_clarification( - db, - chat_id=uuid.UUID(str(chat_id)), - message_id=uuid.UUID(str(query_id)), - kind=clar_kind, - original_query=combined_agent_query, - payload=clarification_payload or {}, - categories=[str(c) for c in categories], - method_tool_names=method_names, - bound_tool_names=bound_names, - ) - log.info(f"ChatID: {chat_id} - Successfully created clarification record (preflight): id={clar_id}") - except Exception as e: - log.warning(f"ChatID: {chat_id} - Failed to create clarification record (preflight): {e}") - - # Clear further tool calls and break - response.tool_calls = [] if hasattr(response, "tool_calls") else None - break - - # Generate action summary for user approval - action_summary = await ActionSummaryGenerator.generate_action_summary([tool_call_dict], db, context=action_context) - if action_summary: - # Add artifact_id to action_summary for consistent ID across planning and execution - artifact_id = str(uuid.uuid4()) # Generate dummy artifact ID for artifacts table (to be implemented) - action_summary["artifact_id"] = artifact_id - # Also include the planned sequence number for UI ordering - with contextlib.suppress(Exception): - action_summary["sequence"] = current_step - - # Clean tool_args to remove non-UUID values that should be resolved during execution - cleaned_args = _clean_tool_args_for_storage(tool_args) - - # Enhance action summary with complete data BEFORE streaming - # Pass tool_args (not cleaned_args) because we need the actual UUIDs - try: - # Add project identifier if available - await _add_project_identifier(action_summary, tool_args) - - # Add workitem/epic complete data for update operations - await _enhance_action_summary_with_complete_data(action_summary, tool_name, tool_args) - except Exception as e: - log.warning(f"Error enhancing action summary before streaming: {e}") - - # Suppress duplicate planned actions within a single planning session - try: - dupe_key = f"{tool_name}:{json.dumps(cleaned_args, sort_keys=True, default=str)}" - except Exception: - dupe_key = f"{tool_name}:{str(cleaned_args)}" - if dupe_key in planned_action_keys: - continue - - # Build planning context from previous retrieval steps - planning_context = { - "original_query": combined_agent_query, - "planning_timestamp": datetime.datetime.utcnow().isoformat(), - "conversation_context": { - "has_conversation_history": len(conversation_history) > 0, - "previous_message_count": len(conversation_history), - }, - } - - # Capture retrieval results that informed this action planning - retrieval_context = [] - for step in tool_flow_steps: - # Include retrieval steps from this planning session - if ( - step.get("is_planned") is False # Retrieval tools are not planned - and step.get("execution_data", {}).get("retrieval_result") - ): - retrieval_info = { - "tool_name": step.get("tool_name"), - "query": step.get("execution_data", {}).get("tool_query"), - "result_preview": step.get("execution_data", {}).get("result_preview"), - "execution_success": step.get("execution_success"), - } - # Include structured results if available - if step.get("execution_data", {}).get("structured_result"): - retrieval_info["structured_data"] = step.get("execution_data", {}).get("structured_result") - retrieval_context.append(retrieval_info) - - planning_context["retrieval_context"] = retrieval_context - planning_context["retrieval_steps_count"] = len(retrieval_context) - # Enhanced execution data for planned actions - enhanced_execution_data = { - "args": cleaned_args, - "args_raw": tool_args, - "action_summary": action_summary, - "tool_id": tool_id, - "artifact_id": artifact_id, # Artifact ID for frontend - "planning_context": planning_context, # Rich context for follow-ups, - "sequence": current_step, - } - - # Create artifact record in database - try: - from pi.services.retrievers.pg_store.action_artifact import create_action_artifact - - # Extract entity and action types from tool name - entity_type = _extract_entity_type_from_tool_name(tool_name) - action_type = _extract_action_type_from_tool_name(tool_name) - - # Prepare artifact data using the enhanced execution data - artifact_data = { - "planning_data": action_summary, - "tool_args": cleaned_args, - "tool_args_raw": tool_args, - "planning_context": planning_context, - "tool_id": tool_id, - } - - # Inject artifact_sub_type into planning_data.parameters for add/remove container operations - try: - if isinstance(action_summary, dict): - tn = action_summary.get("tool_name", "") - if isinstance(tn, str) and "_" in tn: - parts = tn.split("_") - if len(parts) > 2: - action_word = parts[1] - if action_word in ("add", "remove"): - tail = "_".join(parts[2:]) - sub_type = None - if tail in ("work_items", "work_item"): - sub_type = "workitem" - elif isinstance(tail, str) and len(tail) > 1 and tail.endswith("s"): - sub_type = tail[:-1] - elif tail: - sub_type = tail - - if sub_type: - params = action_summary.get("parameters") - if not isinstance(params, dict): - params = {} - action_summary["parameters"] = params - if "artifact_sub_type" not in params: - params["artifact_sub_type"] = sub_type - - # Normalize container properties keys for persisted planning_data - try: - # Determine container entity key from previously derived entity_type - container_key = entity_type if isinstance(entity_type, str) else None - if container_key and isinstance(params.get(container_key), dict): - container_block = params.get(container_key) - properties_block = ( - container_block.get("properties") if isinstance(container_block, dict) else None - ) - if isinstance(properties_block, dict): - # Rename issues -> workitem for workitem subtype - if ( - sub_type == "workitem" - and "issues" in properties_block - and "workitem" not in properties_block - ): - properties_block["workitem"] = properties_block.pop("issues") - # Convert issue_id -> workitem list when feasible - if ( - sub_type == "workitem" - and "issue_id" in properties_block - and "workitem" not in properties_block - ): - single_val = properties_block.get("issue_id") - normalized_list = [] - if isinstance(single_val, dict): - if "id" in single_val or "name" in single_val: - # Keep only commonly used fields - entry = {} - if "id" in single_val: - entry["id"] = single_val["id"] - if "name" in single_val: - entry["name"] = single_val["name"] - if "identifier" in single_val: - entry["identifier"] = single_val["identifier"] - if entry: - normalized_list.append(entry) - else: - normalized_list.append({"name": str(single_val)}) - elif single_val is not None: - normalized_list.append({"name": str(single_val)}) - if normalized_list: - properties_block["workitem"] = normalized_list - except Exception: - pass - except Exception: - # Non-fatal enrichment - pass - - # Create the artifact record - artifact_result = await create_action_artifact( - db=db, - chat_id=chat_id, - entity=entity_type, - action=action_type, - data=artifact_data, - message_id=query_id, - sequence=current_step, - is_executed=False, - ) - - if artifact_result["message"] == "success": - # Update artifact_id to use the actual database ID - actual_artifact_id = str(artifact_result["artifact"].id) - enhanced_execution_data["artifact_id"] = actual_artifact_id - action_summary["artifact_id"] = actual_artifact_id - - else: - log.error(f"Failed to create artifact for {tool_name}: {artifact_result.get("error")}") - - except Exception as e: - log.error(f"Error creating artifact for {tool_name}: {e}") - # Continue with dummy artifact_id if creation fails - - # Store planned action in flow steps for later execution - planned_action_data = { - "step_order": current_step, - "step_type": FlowStepType.TOOL, - "tool_name": tool_name, - "content": standardize_flow_step_content(action_summary, FlowStepType.TOOL), - "execution_data": enhanced_execution_data, - "is_executed": False, - "is_planned": True, # This is a planned action that requires user approval - "execution_success": ExecutionStatus.PENDING, # Not yet attempted - } - tool_flow_steps.append(planned_action_data) - planned_actions.append(action_summary) - planned_action_keys.add(dupe_key) - - # Yield action summary for user approval - yield ActionSummaryGenerator.format_for_streaming(action_summary, str(query_id)) - # yield "\n" - - # Create a tool message to satisfy LLM conversation flow - # This indicates the action was planned rather than executed - tool_message = ToolMessage( - content=f"Action '{action_summary["action"]}' has been planned for user approval. The action will be executed after user confirmation.", # noqa: E501 - tool_call_id=tool_id, - ) - tool_messages.append(tool_message) - - current_step += 1 - else: - # If action summary generation failed, we still need to create a tool message - # to satisfy the LLM conversation flow - tool_message = ToolMessage(content=f"Failed to generate action summary for {tool_name}", tool_call_id=tool_id) - tool_messages.append(tool_message) - current_step += 1 - else: - # For non-action tools (like retrieval), execute immediately - user_friendly_tool_name = chatbot_instance._tool_name_shown_to_user(tool_name) - - # Yield execution message as reasoning block for flow tracking - # Include the tool arguments to show what query is being executed - tool_query = _format_tool_query_for_display(tool_name, tool_args, combined_agent_query) - yield f"πspecial reasoning blockπ: 🔧 Executing: {user_friendly_tool_name}: {tool_query}\n\n" - - # Find and execute the tool - tool_func = next((t for t in combined_tools if t.name == tool_name), None) - if tool_func: - execution_success = ExecutionStatus.SUCCESS - execution_error = None - - try: - # Try async invoke first, fall back to sync if needed - if hasattr(tool_func, "ainvoke"): - result = await tool_func.ainvoke(tool_args) - else: - result = tool_func.invoke(tool_args) - - # Log tool result for debugging (truncate to keep logs sane) - try: - _result_preview = str(result) - if len(_result_preview) > 2000: - _result_preview = _result_preview[:2000] + "... [truncated]" - log.info(f"ChatID: {chat_id} - TOOL RESULT ({tool_name}): {_result_preview}") - except Exception: - pass - - # Create tool message for conversation continuity - tool_message = ToolMessage(content=str(result), tool_call_id=tool_id) - tool_messages.append(tool_message) - - except Exception as tool_error: - log.warning(f"Tool execution failed: {str(tool_error)}") - result = f"Error: {str(tool_error)}" - execution_success = ExecutionStatus.FAILED - execution_error = str(tool_error) - tool_message = ToolMessage(content=str(result), tool_call_id=tool_id) - tool_messages.append(tool_message) - - # Track the tool execution with enhanced context storage - enhanced_execution_data = { - "args": tool_args, - "retrieval_result": str(result), # Store the actual retrieval result - "tool_query": tool_query, # Store the query that was executed - "execution_timestamp": datetime.datetime.utcnow().isoformat(), - } - - # Parse and store structured data if possible - try: - # Try to extract structured information from result - if hasattr(result, "__dict__"): - # For structured objects, store key attributes - enhanced_execution_data["structured_result"] = { - key: value - for key, value in result.__dict__.items() - if not key.startswith("_") and isinstance(value, (str, int, float, bool, list, dict)) - } - elif isinstance(result, dict): - enhanced_execution_data["structured_result"] = result - elif isinstance(result, list) and len(result) > 0: - enhanced_execution_data["result_count"] = len(result) - # Store first few items as examples - enhanced_execution_data["result_preview"] = result[:3] if len(result) > 3 else result - except Exception as parse_error: - log.debug(f"Could not parse structured result for {tool_name}: {parse_error}") - - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL, - "tool_name": tool_name, - "content": standardize_flow_step_content(result, FlowStepType.TOOL), - "execution_data": enhanced_execution_data, - "is_executed": False, # Retrieval tools are not "executed" by user - they run automatically - "is_planned": False, # Retrieval tools are automatically executed, not planned - "execution_success": execution_success, - "execution_error": execution_error, - }) - current_step += 1 - else: - # Don't show tool not found errors to user - keep it internal - log.warning(f"Tool {tool_name} not found") - tool_message = ToolMessage(content=f"Tool {tool_name} not found", tool_call_id=tool_id) - tool_messages.append(tool_message) - - # If clarification was requested, stop further planning - if clarification_requested: - break - - # Add tool results to conversation and continue - messages.extend(tool_messages) - # Get next response from LLM - response = await llm_with_method_tools.ainvoke(messages) - - messages.append(response) - - # Log the complete LLM response for this iteration - try: - response_content = str(getattr(response, "content", "") or "").strip() - tool_calls = getattr(response, "tool_calls", None) - - log.info(f"ChatID: {chat_id} - LLM Response Content (iteration {iteration_count}): {response_content}") - if tool_calls: - log.info(f"ChatID: {chat_id} - LLM Tool Calls (iteration {iteration_count}): {tool_calls}") - else: - log.info(f"ChatID: {chat_id} - LLM Tool Calls (iteration {iteration_count}): None") - - except Exception as e: - log.warning(f"ChatID: {chat_id} - Failed to log LLM response for iteration {iteration_count}: {e}") - - # Log planner decisions for this iteration - try: - if hasattr(response, "tool_calls") and getattr(response, "tool_calls", None): - tool_calls_list = getattr(response, "tool_calls", []) or [] - tool_names = [] - for _tc in tool_calls_list: - if isinstance(_tc, dict): - tool_names.append(_tc.get("name", "")) - else: - tool_names.append(getattr(_tc, "name", "")) - - reasoning_text = str(getattr(response, "content", "") or "").strip() - reason_preview = reasoning_text or "(none)" - - log.info(f"ChatID: {chat_id} - Planner selected tools (iteration {iteration_count}): {tool_names}") - log.info(f"ChatID: {chat_id} - Planner reasoning (iteration {iteration_count}): {reason_preview}") - else: - # Log when no tool calls are made in iteration - reasoning_text = str(getattr(response, "content", "") or "").strip() - log.info(f"ChatID: {chat_id} - Planner selected tools (iteration {iteration_count}): []") - log.info(f"ChatID: {chat_id} - Planner reasoning (iteration {iteration_count}, no tools): {reasoning_text}") - except Exception as e: - log.warning(f"ChatID: {chat_id} - Failed to log planner decisions for iteration {iteration_count}: {e}") - - # Record planner decision for this iteration (selected tools + reasoning) - try: - if hasattr(response, "tool_calls") and getattr(response, "tool_calls", None): - for _tc in getattr(response, "tool_calls", []) or []: - if isinstance(_tc, dict): - _name = _tc.get("name", "") - _args = _tc.get("args", {}) - _id = _tc.get("id", "") - else: - _name = getattr(_tc, "name", "") - _args = getattr(_tc, "args", {}) - _id = getattr(_tc, "id", "") - selected_calls.append({"name": _name, "args": _args, "id": _id}) - - reasoning_text = str(getattr(response, "content", "") or "").strip() - - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL, - "tool_name": "planner_tool_selection", - "content": standardize_flow_step_content( - {"selected_tool_calls": selected_calls, "reasoning": reasoning_text}, FlowStepType.TOOL - ), - "execution_data": { - "iteration": iteration_count, - "selected_tool_calls": selected_calls, - "reasoning": reasoning_text, - }, - "is_planned": False, - "is_executed": False, - "execution_success": ExecutionStatus.PENDING, - }) - current_step += 1 - except Exception: - pass - - # Update loop condition for next iteration - loop_condition = ( - (hasattr(response, "tool_calls") and getattr(response, "tool_calls", None)) or len(planned_actions) == 0 - ) and iteration_count < max_iterations - - # Log why the tool selection loop exited - if clarification_requested: - # Persist any steps recorded so far (including clarification) - if tool_flow_steps: - with contextlib.suppress(Exception): - async with get_streaming_db_session() as _subdb: - await _upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=tool_flow_steps, - db=_subdb, - ) - # End stream without free-form content - yield "__FINAL_RESPONSE__" - return - elif iteration_count >= max_iterations: - log.warning(f"Tool selection loop exited due to maximum iterations ({max_iterations}) reached") - if len(planned_actions) == 0: - # Signal failure and stop before emitting any free-form LLM content - msg = "An unexpected error occurred. Please try again later." - yield msg - # Persist any retrieval steps captured so far - # Append planning summary before persisting steps - try: - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL, - "tool_name": "planner_summary", - "content": "Method planning summary", - "execution_data": { - "iteration_count": iteration_count, - "planned_actions_count": len(planned_actions), - "loop_warning_detected": loop_warning_detected, - "tool_calls_count": len(tool_call_history), - "no_actions_planned": True, - }, - "is_planned": False, - "is_executed": False, - "execution_success": ExecutionStatus.SUCCESS, - }) - current_step += 1 - except Exception: - pass - if tool_flow_steps: - with contextlib.suppress(Exception): - async with get_streaming_db_session() as _subdb: - await _upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=tool_flow_steps, - db=_subdb, - ) - # Do not include free-form content; end stream - yield f"__FINAL_RESPONSE__{msg}" - return - elif len(planned_actions) == 0: - log.warning("Tool selection loop exited without planning any actions - checking for LLM explanation") - - # Check if LLM provided meaningful explanation for why no actions were planned - llm_explanation = None - if hasattr(response, "content") and response.content: - content = str(response.content).strip() - # If LLM provided actual explanation (not just the NO_ACTIONS_PLANNED token), use it - if content and content != "NO_ACTIONS_PLANNED": - llm_explanation = content - log.info(f"ChatID: {chat_id} - Using LLM explanation for no-action scenario: {llm_explanation[:100]}...") - - # Use LLM explanation if available, otherwise show generic refusal - if llm_explanation: - # LLM provided context-aware explanation (e.g., "no unassigned items found") - yield mask_uuids_in_text(llm_explanation) + "\n" - yield f"__FINAL_RESPONSE__{llm_explanation}" - else: - # No meaningful LLM explanation - show generic refusal - refusal_message = ( - "I understand you'd like to perform this action, but I wasn't able to plan any actions for this request with the available tools.\n\n" # noqa: E501 - "**I can help you with:** work-items (create, update, assign, move), projects (create, list, manage), " - "cycles (create, manage, add work-items), modules (create, manage, add work-items), " - "labels and states (create, update), create pages\n\n" - "Feel free to ask me about any of these supported actions, or let me know if you'd like help with something else!" - ) - yield refusal_message - yield f"__FINAL_RESPONSE__{refusal_message}" - - # Persist any retrieval steps captured so far - # Append planning summary before persisting steps - try: - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL, - "tool_name": "planner_summary", - "content": "Method planning summary", - "execution_data": { - "iteration_count": iteration_count, - "planned_actions_count": len(planned_actions), - "loop_warning_detected": loop_warning_detected, - "tool_calls_count": len(tool_call_history), - "no_actions_planned": len(planned_actions) == 0, - "exited_due_to_max_iterations": True, - }, - "is_planned": False, - "is_executed": False, - "execution_success": ExecutionStatus.SUCCESS, - }) - current_step += 1 - except Exception: - pass - if tool_flow_steps: - with contextlib.suppress(Exception): - async with get_streaming_db_session() as _subdb: - await _upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=tool_flow_steps, - db=_subdb, - ) - return - else: - log.debug(f"Tool selection loop completed successfully after {iteration_count} iterations with {len(planned_actions)} planned actions") - - # Handle final response content - final_response_chunks = [] - - if hasattr(response, "content") and response.content: - content = str(response.content).strip() - - # Check if LLM returned NO_ACTIONS_PLANNED token - if content == "NO_ACTIONS_PLANNED": - # Track explicit no-actions token in flow steps - try: - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL, - "tool_name": "planner_no_actions", - "content": "NO_ACTIONS_PLANNED", - "execution_data": { - "reasoning": None, - "iteration_count": iteration_count, - }, - "is_planned": False, - "is_executed": False, - "execution_success": ExecutionStatus.SUCCESS, - }) - current_step += 1 - log.info(f"ChatID: {chat_id} - Planner returned NO_ACTIONS_PLANNED token") - except Exception: - pass - # Convert to a more user-friendly message - pure LLM decision - friendly_response = ( - "I understand you'd like to perform an action, but I wasn't able to plan any actions for this request with the available tools.\n\n" # noqa: E501 - "**What you can try instead:**\n" - "1. Try rephrasing your request with more specific action words (create, update, move, assign, etc.)\n" - "2. Break down complex requests into smaller, specific actions\n" - "3. Ask me to search for existing items first, then perform actions on them\n\n" - "**I can help you with:** work-items (create, update, assign, move), projects (create, list, manage), " # noqa: E501 - "cycles (create, manage, add work-items), modules (create, manage, add work-items), " - "labels and states (create, update), create pages\n\n" - "Feel free to ask me about any of these supported actions, or let me know if you'd like help with something else!" # noqa: E501 - ) - final_response_chunks.append(friendly_response + "\n") - yield friendly_response + "\n" - else: - final_response_chunks.append(content + "\n") - yield mask_uuids_in_text(content) + "\n" - else: - content = "Planned these actions for you" - final_response_chunks.append(content + "\n") - yield content + "\n" - - # Record tool executions in database - # Append planning summary before persisting steps - try: - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL, - "tool_name": "planner_summary", - "content": "Method planning summary", - "execution_data": { - "iteration_count": iteration_count, - "planned_actions_count": len(planned_actions), - "loop_warning_detected": loop_warning_detected, - "tool_calls_count": len(tool_call_history), - "no_actions_planned": (len(planned_actions) == 0) - or (hasattr(response, "content") and str(getattr(response, "content")).strip() == "NO_ACTIONS_PLANNED"), - }, - "is_planned": False, - "is_executed": False, - "execution_success": ExecutionStatus.SUCCESS, - }) - current_step += 1 - except Exception: - pass - if tool_flow_steps: - async with get_streaming_db_session() as _subdb: - flow_step_result = await _upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=tool_flow_steps, - db=_subdb, - ) - - if flow_step_result["message"] != "success": - log.warning("Failed to record action execution in database") - - # Return the complete response for storage in chat history - if final_response_chunks: - # Yield a special signal that can be detected by the calling function - # This follows the same pattern as response_processor.py - final_response = "".join(final_response_chunks) - yield f"__FINAL_RESPONSE__{final_response}" - else: - # Signal end without free-form content - yield "__FINAL_RESPONSE__" - - except Exception: - # Log full traceback for diagnosis - log.error(f"ChatID: {chat_id} - Error in action execution", exc_info=True) - # Emit user-friendly error and final response so caller can persist - error_msg = "An unexpected error occurred. Please try again later." - try: - yield error_msg - yield f"__FINAL_RESPONSE__{error_msg}" - except Exception: - # Best effort; if streaming breaks here, nothing else we can do - pass - return diff --git a/apps/pi/pi/services/chat/helpers/action_property_mapper.py b/apps/pi/pi/services/chat/helpers/action_property_mapper.py new file mode 100644 index 0000000000..c481e6b2fd --- /dev/null +++ b/apps/pi/pi/services/chat/helpers/action_property_mapper.py @@ -0,0 +1,588 @@ +""" +Helper module to map flat tool arguments to the nested 'properties' structure +required for the action summary in the UI. +""" + +import logging +import re +from typing import Any +from typing import Dict + +log = logging.getLogger(__name__) + + +def is_uuid(value: str) -> bool: + """Check if a string is a valid UUID.""" + uuid_pattern = re.compile(r"^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$", re.IGNORECASE) + return bool(uuid_pattern.match(value.strip())) + + +def map_workitem_properties(tool_args: Dict[str, Any]) -> Dict[str, Any]: + """Map properties for workitem creation/update tools.""" + properties: Dict[str, Any] = {} + + # Priority: {"name": "low"} + if "priority" in tool_args and tool_args["priority"]: + properties["priority"] = {"name": tool_args["priority"]} + + # State: {"id": "uuid"} or {"name": "Done"} + if "state" in tool_args and tool_args["state"]: + state_val = tool_args["state"] + if is_uuid(state_val): + properties["state"] = {"id": state_val} + else: + properties["state"] = {"name": state_val} + + # Assignees: [{"id": "uuid"}, ...] + if "assignees" in tool_args and tool_args["assignees"]: + assignees = tool_args["assignees"] + if isinstance(assignees, list): + properties["assignees"] = [{"id": assignee_id} for assignee_id in assignees if assignee_id] + + # Labels: [{"id": "uuid"}, ...] + if "labels" in tool_args and tool_args["labels"]: + labels = tool_args["labels"] + if isinstance(labels, list): + properties["labels"] = [{"id": label_id} for label_id in labels if label_id] + + # Dates: {"name": "YYYY-MM-DD"} + if "start_date" in tool_args and tool_args["start_date"]: + properties["start_date"] = {"name": tool_args["start_date"]} + + if "target_date" in tool_args and tool_args["target_date"]: + properties["target_date"] = {"name": tool_args["target_date"]} + + # Type ID: {"id": "uuid"} + if "type_id" in tool_args and tool_args["type_id"]: + properties["type_id"] = {"id": tool_args["type_id"]} + + # Parent: {"id": "uuid"} + if "parent" in tool_args and tool_args["parent"]: + properties["parent"] = {"id": tool_args["parent"]} + + # External ID: {"name": "external-123"} + if "external_id" in tool_args and tool_args["external_id"]: + properties["external_id"] = {"name": tool_args["external_id"]} + + # External Source: {"name": "jira"} + if "external_source" in tool_args and tool_args["external_source"]: + properties["external_source"] = {"name": tool_args["external_source"]} + + # Is Draft: {"name": true/false} + if "is_draft" in tool_args and tool_args["is_draft"] is not None: + properties["is_draft"] = bool(tool_args["is_draft"]) + + return properties + + +def map_project_properties(tool_args: Dict[str, Any]) -> Dict[str, Any]: + """Map properties for project creation/update tools.""" + properties: Dict[str, Any] = {} + + # Identifier: {"name": "PROJ"} + if "identifier" in tool_args and tool_args["identifier"]: + properties["identifier"] = {"name": tool_args["identifier"]} + + # Description: {"name": "text"} - UI often expects simple text wrapped + if "description" in tool_args and tool_args["description"]: + properties["description"] = {"name": tool_args["description"]} + + # Project Lead: {"id": "uuid"} + if "project_lead" in tool_args and tool_args["project_lead"]: + properties["project_lead"] = {"id": tool_args["project_lead"]} + + # Default Assignee: {"id": "uuid"} + if "default_assignee" in tool_args and tool_args["default_assignee"]: + properties["default_assignee"] = {"id": tool_args["default_assignee"]} + + # Icon Prop: {"name": {...}} - JSON object + if "icon_prop" in tool_args and tool_args["icon_prop"]: + properties["icon_prop"] = {"name": tool_args["icon_prop"]} + + # Emoji: {"name": "🚀"} + if "emoji" in tool_args and tool_args["emoji"]: + properties["emoji"] = {"name": tool_args["emoji"]} + + # Cover Image: {"name": "url"} + if "cover_image" in tool_args and tool_args["cover_image"]: + properties["cover_image"] = {"name": tool_args["cover_image"]} + + # Module View: {"name": true/false} + if "module_view" in tool_args and tool_args["module_view"] is not None: + properties["module_view"] = bool(tool_args["module_view"]) + + # Cycle View: {"name": true/false} + if "cycle_view" in tool_args and tool_args["cycle_view"] is not None: + properties["cycle_view"] = bool(tool_args["cycle_view"]) + + # Issue Views View: {"name": true/false} + if "issue_views_view" in tool_args and tool_args["issue_views_view"] is not None: + properties["issue_views_view"] = bool(tool_args["issue_views_view"]) + + # Page View: {"name": true/false} + if "page_view" in tool_args and tool_args["page_view"] is not None: + properties["page_view"] = bool(tool_args["page_view"]) + + # Intake View: {"name": true/false} + if "intake_view" in tool_args and tool_args["intake_view"] is not None: + properties["intake_view"] = bool(tool_args["intake_view"]) + + # Guest View All Features: {"name": true/false} + if "guest_view_all_features" in tool_args and tool_args["guest_view_all_features"] is not None: + properties["guest_view_all_features"] = bool(tool_args["guest_view_all_features"]) + + # Archive In: {"name": 12} - number of months + if "archive_in" in tool_args and tool_args["archive_in"] is not None: + properties["archive_in"] = int(tool_args["archive_in"]) + + # Close In: {"name": 12} - number of months + if "close_in" in tool_args and tool_args["close_in"] is not None: + properties["close_in"] = int(tool_args["close_in"]) + + # Timezone: {"name": "UTC"} + if "timezone" in tool_args and tool_args["timezone"]: + properties["timezone"] = {"name": tool_args["timezone"]} + + # Time Tracking Enabled: {"name": true/false} + # Handle both variants: time_tracking_enabled and is_time_tracking_enabled + if "time_tracking_enabled" in tool_args and tool_args["time_tracking_enabled"] is not None: + properties["time_tracking_enabled"] = bool(tool_args["time_tracking_enabled"]) + elif "is_time_tracking_enabled" in tool_args and tool_args["is_time_tracking_enabled"] is not None: + properties["is_time_tracking_enabled"] = bool(tool_args["is_time_tracking_enabled"]) + + # Issue Type Enabled: {"name": true/false} + if "is_issue_type_enabled" in tool_args and tool_args["is_issue_type_enabled"] is not None: + properties["is_issue_type_enabled"] = bool(tool_args["is_issue_type_enabled"]) + + return properties + + +def map_module_properties(tool_args: Dict[str, Any]) -> Dict[str, Any]: + """Map properties for module creation/update tools and work item operations.""" + properties: Dict[str, Any] = {} + + # Start Date: {"name": "YYYY-MM-DD"} + if "start_date" in tool_args and tool_args["start_date"]: + properties["start_date"] = {"name": tool_args["start_date"]} + + # Target Date: {"name": "YYYY-MM-DD"} + if "target_date" in tool_args and tool_args["target_date"]: + properties["target_date"] = {"name": tool_args["target_date"]} + + # Status: {"name": "backlog/unstarted/started/completed/cancelled/paused"} + if "status" in tool_args and tool_args["status"]: + properties["status"] = {"name": tool_args["status"]} + + # Lead: {"id": "uuid"} + if "lead" in tool_args and tool_args["lead"]: + properties["lead"] = {"id": tool_args["lead"]} + + # Members: [{"id": "uuid"}, ...] + if "members" in tool_args and tool_args["members"]: + members = tool_args["members"] + if isinstance(members, list): + properties["members"] = [{"id": member_id} for member_id in members if member_id] + + # Issues: [{"id": "uuid"}, ...] - for modules_add_work_items + if "issues" in tool_args and tool_args["issues"]: + issues = tool_args["issues"] + if isinstance(issues, list): + properties["issues"] = [{"id": issue_id} for issue_id in issues if issue_id] + + # Issue ID: {"id": "uuid"} - for modules_remove_work_item + if "issue_id" in tool_args and tool_args["issue_id"]: + properties["issue_id"] = {"id": tool_args["issue_id"]} + + # External ID: {"name": "external-123"} + if "external_id" in tool_args and tool_args["external_id"]: + properties["external_id"] = {"name": tool_args["external_id"]} + + # External Source: {"name": "jira"} + if "external_source" in tool_args and tool_args["external_source"]: + properties["external_source"] = {"name": tool_args["external_source"]} + + return properties + + +def map_cycle_properties(tool_args: Dict[str, Any]) -> Dict[str, Any]: + """Map properties for cycle creation/update tools and work item operations.""" + properties: Dict[str, Any] = {} + + # Start Date: {"name": "YYYY-MM-DD"} + if "start_date" in tool_args and tool_args["start_date"]: + properties["start_date"] = {"name": tool_args["start_date"]} + + # End Date: {"name": "YYYY-MM-DD"} + if "end_date" in tool_args and tool_args["end_date"]: + properties["end_date"] = {"name": tool_args["end_date"]} + + # Owned By: {"id": "uuid"} + if "owned_by" in tool_args and tool_args["owned_by"]: + properties["owned_by"] = {"id": tool_args["owned_by"]} + + # Issues: [{"id": "uuid"}, ...] - for cycles_add_work_items + if "issues" in tool_args and tool_args["issues"]: + issues = tool_args["issues"] + if isinstance(issues, list): + properties["issues"] = [{"id": issue_id} for issue_id in issues if issue_id] + + # Issue ID: {"id": "uuid"} - for cycles_remove_work_item + if "issue_id" in tool_args and tool_args["issue_id"]: + properties["issue_id"] = {"id": tool_args["issue_id"]} + + # New Cycle ID: {"id": "uuid"} - for cycles_transfer_work_items + if "new_cycle_id" in tool_args and tool_args["new_cycle_id"]: + properties["new_cycle_id"] = {"id": tool_args["new_cycle_id"]} + + # External ID: {"name": "external-123"} + if "external_id" in tool_args and tool_args["external_id"]: + properties["external_id"] = {"name": tool_args["external_id"]} + + # External Source: {"name": "jira"} + if "external_source" in tool_args and tool_args["external_source"]: + properties["external_source"] = {"name": tool_args["external_source"]} + + # Timezone: {"name": "UTC"} + if "timezone" in tool_args and tool_args["timezone"]: + properties["timezone"] = {"name": tool_args["timezone"]} + + return properties + + +def map_intake_properties(tool_args: Dict[str, Any]) -> Dict[str, Any]: + """Map properties for intake creation/update tools.""" + properties: Dict[str, Any] = {} + + # Priority: {"name": "low/medium/high/urgent/none"} + if "priority" in tool_args and tool_args["priority"]: + properties["priority"] = {"name": tool_args["priority"]} + + # Assignee: {"id": "uuid"} + if "assignee" in tool_args and tool_args["assignee"]: + properties["assignee"] = {"id": tool_args["assignee"]} + + # Reporter: {"id": "uuid"} + if "reporter" in tool_args and tool_args["reporter"]: + properties["reporter"] = {"id": tool_args["reporter"]} + + # Labels: [{"id": "uuid"}, ...] + if "labels" in tool_args and tool_args["labels"]: + labels = tool_args["labels"] + if isinstance(labels, list): + properties["labels"] = [{"id": label_id} for label_id in labels if label_id] + + return properties + + +def map_state_properties(tool_args: Dict[str, Any]) -> Dict[str, Any]: + """Map properties for state creation/update tools.""" + properties: Dict[str, Any] = {} + + # Color: {"name": "#FF5733"} + if "color" in tool_args and tool_args["color"]: + properties["color"] = {"name": tool_args["color"]} + + # Group: {"name": "backlog/unstarted/started/completed/cancelled"} + if "group" in tool_args and tool_args["group"]: + properties["group"] = {"name": tool_args["group"]} + + # Sequence: {"name": "1"} + if "sequence" in tool_args and tool_args["sequence"] is not None: + properties["sequence"] = {"name": str(tool_args["sequence"])} + + # Is Triage: {"name": "true/false"} + if "is_triage" in tool_args and tool_args["is_triage"] is not None: + properties["is_triage"] = {"name": str(bool(tool_args["is_triage"])).lower()} + + # Default: {"name": "true/false"} + if "default" in tool_args and tool_args["default"] is not None: + properties["default"] = {"name": str(bool(tool_args["default"])).lower()} + + # External ID: {"name": "external-123"} + if "external_id" in tool_args and tool_args["external_id"]: + properties["external_id"] = {"name": tool_args["external_id"]} + + # External Source: {"name": "jira"} + if "external_source" in tool_args and tool_args["external_source"]: + properties["external_source"] = {"name": tool_args["external_source"]} + + # Description: {"name": "text"} + if "description" in tool_args and tool_args["description"]: + properties["description"] = {"name": tool_args["description"]} + + return properties + + +def map_label_properties(tool_args: Dict[str, Any]) -> Dict[str, Any]: + """Map properties for label creation/update tools.""" + properties: Dict[str, Any] = {} + + # Color: {"name": "#FF5733"} + if "color" in tool_args and tool_args["color"]: + properties["color"] = {"name": tool_args["color"]} + + # Description: {"name": "text"} + if "description" in tool_args and tool_args["description"]: + properties["description"] = {"name": tool_args["description"]} + + # Parent: {"id": "uuid"} - for nested labels + if "parent" in tool_args and tool_args["parent"]: + properties["parent"] = {"id": tool_args["parent"]} + + # Sort Order: {"name": "1.5"} + if "sort_order" in tool_args and tool_args["sort_order"] is not None: + properties["sort_order"] = {"name": str(tool_args["sort_order"])} + + # External ID: {"name": "external-123"} + if "external_id" in tool_args and tool_args["external_id"]: + properties["external_id"] = {"name": tool_args["external_id"]} + + # External Source: {"name": "jira"} + if "external_source" in tool_args and tool_args["external_source"]: + properties["external_source"] = {"name": tool_args["external_source"]} + + return properties + + +def map_worklog_properties(tool_args: Dict[str, Any]) -> Dict[str, Any]: + """Map properties for worklog creation/update tools.""" + properties: Dict[str, Any] = {} + + # Description: {"name": "text"} + if "description" in tool_args and tool_args["description"]: + properties["description"] = {"name": tool_args["description"]} + + # Duration: {"name": "60"} - duration in minutes + if "duration" in tool_args and tool_args["duration"] is not None: + properties["duration"] = {"name": str(tool_args["duration"])} + + # Created By: {"id": "uuid"} + if "created_by" in tool_args and tool_args["created_by"]: + properties["created_by"] = {"id": tool_args["created_by"]} + + # Updated By: {"id": "uuid"} + if "updated_by" in tool_args and tool_args["updated_by"]: + properties["updated_by"] = {"id": tool_args["updated_by"]} + + return properties + + +def map_comment_properties(tool_args: Dict[str, Any]) -> Dict[str, Any]: + """Map properties for comment creation/update tools.""" + properties: Dict[str, Any] = {} + + # Comment HTML: {"name": "

text

"} + if "comment_html" in tool_args and tool_args["comment_html"]: + properties["comment_html"] = {"name": tool_args["comment_html"]} + + # Comment JSON: {"name": "{...}"} - converting dict to string representation + if "comment_json" in tool_args and tool_args["comment_json"] is not None: + import json + + properties["comment_json"] = {"name": json.dumps(tool_args["comment_json"])} + + # Access: {"name": "public/private/..."} + if "access" in tool_args and tool_args["access"]: + properties["access"] = {"name": tool_args["access"]} + + # External ID: {"name": "external-123"} + if "external_id" in tool_args and tool_args["external_id"]: + properties["external_id"] = {"name": tool_args["external_id"]} + + # External Source: {"name": "jira"} + if "external_source" in tool_args and tool_args["external_source"]: + properties["external_source"] = {"name": tool_args["external_source"]} + + return properties + + +def map_link_properties(tool_args: Dict[str, Any]) -> Dict[str, Any]: + """Map properties for link creation/update tools.""" + properties: Dict[str, Any] = {} + + # Title: {"name": "Link Title"} + if "title" in tool_args and tool_args["title"]: + properties["title"] = {"name": tool_args["title"]} + + # URL: {"name": "https://example.com"} + if "url" in tool_args and tool_args["url"]: + properties["url"] = {"name": tool_args["url"]} + + # Metadata: {"name": {...}} - JSON object + if "metadata" in tool_args and tool_args["metadata"]: + import json + + if isinstance(tool_args["metadata"], dict): + properties["metadata"] = {"name": json.dumps(tool_args["metadata"])} + else: + properties["metadata"] = {"name": str(tool_args["metadata"])} + + return properties + + +def map_attachment_properties(tool_args: Dict[str, Any]) -> Dict[str, Any]: + """Map properties for attachment creation/update tools.""" + properties: Dict[str, Any] = {} + + # Name: {"name": "filename.pdf"} + if "name" in tool_args and tool_args["name"]: + properties["name"] = {"name": tool_args["name"]} + + # Type: {"name": "application/pdf"} + if "type" in tool_args and tool_args["type"]: + properties["type"] = {"name": tool_args["type"]} + + # Size: {"name": "1024"} - convert int to string + if "size" in tool_args and tool_args["size"] is not None: + properties["size"] = {"name": str(tool_args["size"])} + + # External ID: {"name": "external-123"} + if "external_id" in tool_args and tool_args["external_id"]: + properties["external_id"] = {"name": tool_args["external_id"]} + + # External Source: {"name": "jira"} + if "external_source" in tool_args and tool_args["external_source"]: + properties["external_source"] = {"name": tool_args["external_source"]} + + # Is Uploaded: {"name": "true/false"} - for update operations + if "is_uploaded" in tool_args and tool_args["is_uploaded"] is not None: + properties["is_uploaded"] = {"name": str(bool(tool_args["is_uploaded"])).lower()} + + return properties + + +def map_property_properties(tool_args: Dict[str, Any]) -> Dict[str, Any]: + """Map properties for property creation/update tools.""" + properties: Dict[str, Any] = {} + + # Property Type: {"name": "TEXT/DATETIME/..."} + if "property_type" in tool_args and tool_args["property_type"]: + properties["property_type"] = {"name": tool_args["property_type"]} + + # Relation Type: {"name": "ONE_TO_ONE/..."} + if "relation_type" in tool_args and tool_args["relation_type"]: + properties["relation_type"] = {"name": tool_args["relation_type"]} + + # Is Required: boolean + if "is_required" in tool_args and tool_args["is_required"] is not None: + properties["is_required"] = bool(tool_args["is_required"]) + + # Default Value: list of strings (keep as-is) + if "default_value" in tool_args and tool_args["default_value"] is not None: + properties["default_value"] = tool_args["default_value"] + + # Settings: dict/object (keep as-is) + if "settings" in tool_args and tool_args["settings"] is not None: + properties["settings"] = tool_args["settings"] + + # Is Active: boolean + if "is_active" in tool_args and tool_args["is_active"] is not None: + properties["is_active"] = bool(tool_args["is_active"]) + + # Is Multi: boolean + if "is_multi" in tool_args and tool_args["is_multi"] is not None: + properties["is_multi"] = bool(tool_args["is_multi"]) + + # Validation Rules: dict/object (keep as-is) + if "validation_rules" in tool_args and tool_args["validation_rules"] is not None: + properties["validation_rules"] = tool_args["validation_rules"] + + # External Source: {"name": "value"} + if "external_source" in tool_args and tool_args["external_source"]: + properties["external_source"] = {"name": tool_args["external_source"]} + + # External ID: {"name": "value"} + if "external_id" in tool_args and tool_args["external_id"]: + properties["external_id"] = {"name": tool_args["external_id"]} + + return properties + + +def map_type_properties(tool_args: Dict[str, Any]) -> Dict[str, Any]: + """Map properties for work item type creation/update tools.""" + properties: Dict[str, Any] = {} + + # Project IDs: [{"id": "uuid"}, ...] + if "project_ids" in tool_args and tool_args["project_ids"]: + project_ids = tool_args["project_ids"] + if isinstance(project_ids, list): + properties["project_ids"] = [{"id": project_id} for project_id in project_ids if project_id] + + # Is Epic: boolean + if "is_epic" in tool_args and tool_args["is_epic"] is not None: + properties["is_epic"] = bool(tool_args["is_epic"]) + + # Is Active: boolean + if "is_active" in tool_args and tool_args["is_active"] is not None: + properties["is_active"] = bool(tool_args["is_active"]) + + # External Source: {"name": "value"} + if "external_source" in tool_args and tool_args["external_source"]: + properties["external_source"] = {"name": tool_args["external_source"]} + + # External ID: {"name": "value"} + if "external_id" in tool_args and tool_args["external_id"]: + properties["external_id"] = {"name": tool_args["external_id"]} + + return properties + + +def map_common_properties(tool_args: Dict[str, Any]) -> Dict[str, Any]: + """Map common properties that might appear in various tools.""" + properties: Dict[str, Any] = {} + + # Handle generic 'name' if it wasn't popped earlier or is needed as property + # Note: 'name' is usually a root parameter, but sometimes duplicated in properties + + return properties + + +def map_tool_properties(artifact_type: str, tool_args: Dict[str, Any]) -> Dict[str, Any]: + """ + Main entry point to map tool arguments to the nested 'properties' structure. + + Args: + artifact_type: Type of the artifact being executed + tool_args: Dictionary of arguments passed to the tool + + Returns: + Dictionary representing the 'properties' field in action summary + """ + properties: Dict[str, Any] = {} + + # Dispatch based on tool name pattern + if artifact_type == "workitem" or artifact_type == "epic": + properties.update(map_workitem_properties(tool_args)) + elif artifact_type == "project": + properties.update(map_project_properties(tool_args)) + elif artifact_type == "module": + properties.update(map_module_properties(tool_args)) + elif artifact_type == "cycle": + properties.update(map_cycle_properties(tool_args)) + elif artifact_type == "intake": + properties.update(map_intake_properties(tool_args)) + elif artifact_type == "label": + properties.update(map_label_properties(tool_args)) + elif artifact_type == "page": + return {} # Page doesn't have any properties other than the basic ones already added in params dict + elif artifact_type == "state": + properties.update(map_state_properties(tool_args)) + elif artifact_type == "worklog": + properties.update(map_worklog_properties(tool_args)) + elif artifact_type == "comment": + properties.update(map_comment_properties(tool_args)) + elif artifact_type == "attachment": + properties.update(map_attachment_properties(tool_args)) + elif artifact_type == "link": + properties.update(map_link_properties(tool_args)) + elif artifact_type == "type": + properties.update(map_type_properties(tool_args)) + elif artifact_type == "property": + properties.update(map_property_properties(tool_args)) + + # Add any other specific mappers here + + # Fallback/Generic handling for remaining keys could be added here + # but for now we stick to explicit mappings to avoid cluttering the UI + + return properties diff --git a/apps/pi/pi/services/chat/helpers/action_summary_generator.py b/apps/pi/pi/services/chat/helpers/action_summary_generator.py deleted file mode 100644 index ca9c03401a..0000000000 --- a/apps/pi/pi/services/chat/helpers/action_summary_generator.py +++ /dev/null @@ -1,1417 +0,0 @@ -"""Action Summary Generator - Converts LLM tool calls to user-friendly descriptions.""" - -import re -from typing import Any -from typing import Dict -from typing import List -from typing import Optional - -from pi import logger - -log = logger.getChild(__name__) - - -class ActionSummaryGenerator: - """Generates user-friendly action summaries from LLM tool calls.""" - - # Mapping of tool names to user-friendly action names - TOOL_ACTION_MAPPING = { - # Work Items - "workitems_create": "Create work-item", - "workitems_update": "Update work-item", - "workitems_create_relation": "Update work-item relations", - "workitems_list": "List work-items", - "workitems_retrieve": "Get work-item details", - "workitems_search": "Search work-items", - "workitems_get_workspace": "Get workspace work-item", - # Epics - "epics_create": "Create epic", - "epics_update": "Update epic", - # Projects - "projects_create": "Create project", - "projects_update": "Update project", - "projects_archive": "Archive project", - "projects_unarchive": "Unarchive project", - "projects_list": "List projects", - "projects_retrieve": "Get project details", - # Cycles - "cycles_create": "Create cycle", - "cycles_update": "Update cycle", - "cycles_archive": "Archive cycle", - "cycles_add_work_items": "Add work-items to cycle", - "cycles_remove_work_item": "Remove work-item from cycle", - "cycles_list": "List cycles", - "cycles_retrieve": "Get cycle details", - # Labels - "labels_create": "Create label", - "labels_list": "List labels", - # States - "states_create": "Create state", - "states_list": "List states", - # Modules - "modules_create": "Create module", - "modules_update": "Update module", - "modules_archive": "Archive module", - "modules_unarchive": "Unarchive module", - "modules_list_archived": "List archived modules", - "modules_add_work_items": "Add work-items to module", - "modules_list_work_items": "List work-items in module", - "modules_remove_work_item": "Remove work-item from module", - "modules_list": "List modules", - "modules_retrieve": "Get module details", - # Users - "users_get_current": "Get current user details", - # Comments - "comments_create": "Add comment", - "comments_list": "List comments", - # Attachments - "attachments_list": "List attachments", - # Links - "links_create": "Create link", - # Worklogs - "worklogs_create": "Create worklog", - "worklogs_list": "List worklogs", - # Intake - "intake_create": "Submit intake work-item", - "intake_list": "List intake work-items", - "intake_delete": "Delete intake work-item", - # Members - "members_get_workspace_members": "List workspace members", - # Activity - "activity_list": "List activities", - # Assets - "assets_list": "List assets", - # Properties - "properties_create": "Create property", - # Types - "types_create": "Create work-item type", - } - - # Parameter display names for better readability - PARAM_DISPLAY_NAMES = { - "name": "Name", - "title": "Title", - "description": "Description", - "assignees": "Assignees", - "start_date": "Start date", - "target_date": "End date", - "priority": "Priority", - "state": "State", - "labels": "Labels", - "project": "Project", - "project_id": "Project Name", - "cycle": "Cycle", - "cycle_id": "Cycle Name", - "module": "Module", - "module_id": "Module Name", - "state_id": "State Name", - "label_id": "Label Name", - "user_id": "User Name", - "assignee_id": "Assignee Name", - "estimate_point": "Estimate", - "point": "Story points", - "external_id": "External ID", - "external_source": "External source", - "is_draft": "Draft status", - "archived_at": "Archive date", - "completed_at": "Completion date", - "sort_order": "Sort order", - "sequence_id": "Sequence ID", - "color": "Color", - "slug": "Slug", - "logo_props": "Logo properties", - "view_props": "View properties", - "backlog_issues": "Backlog issues count", - "total_issues": "Total issues count", - "completed_issues": "Completed issues count", - "started_issues": "Started issues count", - "unstarted_issues": "Unstarted issues count", - "cancelled_issues": "Cancelled issues count", - "total_estimates": "Total estimates", - "completed_estimates": "Completed estimates", - "started_estimates": "Started estimates", - "unstarted_estimates": "Unstarted estimates", - "version": "Version", - "timezone": "Timezone", - "progress_snapshot": "Progress snapshot", - "members": "Members", - "lead": "Lead", - "identifier": "Identifier", - "workspace": "Workspace", - "parent": "Parent", - "is_active": "Active status", - "is_default": "Default status", - "is_triage": "Triage status", - "level": "Level", - "group": "Group", - "sequence": "Sequence", - "default": "Default", - "is_archived": "Archived status", - "is_public": "Public status", - "is_favorite": "Favorite status", - "is_subscribed": "Subscribed status", - "is_watching": "Watching status", - "is_mentions": "Mentions enabled", - "is_reactions": "Reactions enabled", - "is_comments": "Comments enabled", - "is_attachments": "Attachments enabled", - "is_links": "Links enabled", - "is_worklogs": "Worklogs enabled", - "is_estimates": "Estimates enabled", - "is_cycles": "Cycles enabled", - "is_modules": "Modules enabled", - "is_views": "Views enabled", - "is_analytics": "Analytics enabled", - "is_integrations": "Integrations enabled", - "is_webhooks": "Webhooks enabled", - "is_automation": "Automation enabled", - "is_workflows": "Workflows enabled", - "is_templates": "Templates enabled", - "is_imports": "Imports enabled", - "is_exports": "Exports enabled", - "is_backups": "Backups enabled", - "is_restores": "Restores enabled", - "is_migrations": "Migrations enabled", - "is_upgrades": "Upgrades enabled", - "is_downgrades": "Downgrades enabled", - "is_rollbacks": "Rollbacks enabled", - "is_deployments": "Deployments enabled", - "is_releases": "Releases enabled", - "is_builds": "Builds enabled", - "is_tests": "Tests enabled", - "is_linting": "Linting enabled", - "is_formatting": "Formatting enabled", - "is_documentation": "Documentation enabled", - "is_translations": "Translations enabled", - "is_localization": "Localization enabled", - "is_internationalization": "Internationalization enabled", - "is_accessibility": "Accessibility enabled", - "is_security": "Security enabled", - "is_privacy": "Privacy enabled", - "is_compliance": "Compliance enabled", - "is_audit": "Audit enabled", - "is_monitoring": "Monitoring enabled", - "is_logging": "Logging enabled", - "is_metrics": "Metrics enabled", - "is_alerting": "Alerting enabled", - "is_reporting": "Reporting enabled", - "is_dashboard": "Dashboard enabled", - "is_widgets": "Widgets enabled", - "is_charts": "Charts enabled", - "is_graphs": "Graphs enabled", - "is_tables": "Tables enabled", - "is_forms": "Forms enabled", - "is_buttons": "Buttons enabled", - "is_inputs": "Inputs enabled", - "is_selects": "Selects enabled", - "is_checkboxes": "Checkboxes enabled", - "is_radios": "Radio buttons enabled", - "is_textareas": "Text areas enabled", - "is_files": "File inputs enabled", - "is_images": "Image inputs enabled", - "is_videos": "Video inputs enabled", - "is_audios": "Audio inputs enabled", - "is_documents": "Document inputs enabled", - "is_presentations": "Presentation inputs enabled", - "is_spreadsheets": "Spreadsheet inputs enabled", - "is_databases": "Database inputs enabled", - "is_apis": "API inputs enabled", - "is_sockets": "Socket inputs enabled", - "is_streams": "Stream inputs enabled", - "is_batches": "Batch inputs enabled", - "is_schedules": "Schedule inputs enabled", - "is_triggers": "Trigger inputs enabled", - "is_events": "Event inputs enabled", - "is_notifications": "Notification inputs enabled", - "is_emails": "Email inputs enabled", - "is_sms": "SMS inputs enabled", - "is_push": "Push notification inputs enabled", - "is_in_app": "In-app notification inputs enabled", - "is_slack": "Slack notification inputs enabled", - "is_teams": "Teams notification inputs enabled", - "is_discord": "Discord notification inputs enabled", - "is_telegram": "Telegram notification inputs enabled", - "is_whatsapp": "WhatsApp notification inputs enabled", - "is_signal": "Signal notification inputs enabled", - "is_wechat": "WeChat notification inputs enabled", - "is_line": "Line notification inputs enabled", - "is_viber": "Viber notification inputs enabled", - "is_skype": "Skype notification inputs enabled", - "is_zoom": "Zoom notification inputs enabled", - "is_meet": "Google Meet notification inputs enabled", - "is_teams_meeting": "Teams meeting inputs enabled", - "is_webex": "Webex meeting inputs enabled", - "is_gotomeeting": "GoToMeeting inputs enabled", - "is_joinme": "Join.me inputs enabled", - "is_bluejeans": "BlueJeans inputs enabled", - "is_ringcentral": "RingCentral inputs enabled", - "is_8x8": "8x8 inputs enabled", - "is_mitel": "Mitel inputs enabled", - "is_avaya": "Avaya inputs enabled", - "is_cisco": "Cisco inputs enabled", - "is_polycom": "Polycom inputs enabled", - "is_yealink": "Yealink inputs enabled", - "is_grandstream": "Grandstream inputs enabled", - "is_snom": "Snom inputs enabled", - "is_fanvil": "Fanvil inputs enabled", - "is_htek": "HTek inputs enabled", - "is_yeastar": "Yeastar inputs enabled", - "is_3cx": "3CX inputs enabled", - "is_freepbx": "FreePBX inputs enabled", - "is_asterisk": "Asterisk inputs enabled", - "is_freeswitch": "FreeSWITCH inputs enabled", - "is_kamailio": "Kamailio inputs enabled", - "is_opensips": "OpenSIPS inputs enabled", - "is_openser": "OpenSER inputs enabled", - "is_ser": "SER inputs enabled", - "is_mediaproxy": "MediaProxy inputs enabled", - "is_rtpproxy": "RTPproxy inputs enabled", - "is_rtpengine": "RTPengine inputs enabled", - } - - @classmethod - async def generate_action_summary(cls, tool_calls: List[Dict[str, Any]], db=None, context=None) -> Dict[str, Any]: - """ - Generate a user-friendly action summary from LLM tool calls. - - Args: - tool_calls: List of tool call dictionaries from LLM - db: Optional database session for resolving IDs to names - context: Optional context with workspace info for entity resolution - - Returns: - Dictionary containing action summary for user display - """ - if not tool_calls: - return {} - - # For now, handle single tool call (can be extended for multiple) - tool_call = tool_calls[0] - tool_name = tool_call.get("name", "") - tool_args = tool_call.get("args", {}) - - # Get user-friendly action name - action_name = cls.TOOL_ACTION_MAPPING.get(tool_name, tool_name.replace("_", " ").title()) - - # Format parameters for display with entity resolution - use unified normalizer - from pi.services.actions.artifacts.utils import normalize_parameters_structure - - # First normalize the structure for consistency with entity flattening - normalized_params = normalize_parameters_structure(tool_args, flatten_entities=True) - - # Then apply entity resolution for display purposes - formatted_params = await cls._resolve_entities_for_display(normalized_params, db, context, tool_name) - - return { - "action": action_name, - "tool_name": tool_name, - "parameters": formatted_params, - "raw_args": tool_args, # Keep raw args for execution - } - - @classmethod - async def _resolve_entities_for_display( - cls, parameters: Dict[str, Any], db: Optional[Any] = None, context: Optional[Dict[str, Any]] = None, tool_name: Optional[str] = None - ) -> Dict[str, Any]: - """ - Resolve entity IDs to names for better display, while maintaining consistent structure. - - This replaces the complex _format_parameters logic with a simpler approach - that works on the already-normalized parameter structure. - """ - - resolved = parameters.copy() - - # Extract context for entity resolution - context.get("workspace_slug") if isinstance(context, dict) else None - context.get("project_id") if isinstance(context, dict) else None - - # Resolve entities in properties section - if "properties" in resolved and isinstance(resolved["properties"], dict): - properties = resolved["properties"] - resolved_properties: Dict[str, Any] = {} - - for key, value in properties.items(): - if key.endswith("_id") and isinstance(value, str) and cls._is_uuid_like(value): - # Try to resolve ID to name - resolved_name = await cls._resolve_id_to_name(key, value, db) - if resolved_name: - resolved_properties[key.replace("_id", "")] = {"id": value, "name": resolved_name} - else: - resolved_properties[key] = value - elif key == "state" and isinstance(value, dict) and "name" in value and "id" not in value: - # Handle state field that only has name - check if it's a UUID or actual name - state_identifier = value["name"] - try: - if cls._is_uuid_like(state_identifier): - # It's a UUID, resolve by ID - from pi.app.api.v1.helpers.plane_sql_queries import get_state_details_by_id - - state_result = await get_state_details_by_id(state_identifier) - if state_result: - resolved_properties[key] = {"id": state_identifier, "name": state_result["name"], "group": state_result["group"]} - else: - resolved_properties[key] = value - else: - # It's a name, resolve by name - from pi.app.api.v1.helpers.plane_sql_queries import search_state_by_name - - workspace_slug = context.get("workspace_slug") if isinstance(context, dict) else None - project_id = context.get("project_id") if isinstance(context, dict) else None - - state_result = await search_state_by_name(state_identifier, project_id, workspace_slug) - if state_result: - resolved_properties[key] = {"id": state_result["id"], "name": state_result["name"], "group": state_result["group"]} - else: - resolved_properties[key] = value - except Exception as e: - log.warning(f"Error resolving state '{state_identifier}': {e}") - resolved_properties[key] = value - elif key in ["assignees", "labels", "modules"] and isinstance(value, list): - # Resolve lists of entities - resolved_list = [] - for item in value: - if isinstance(item, str) and cls._is_uuid_like(item): - resolved_name = await cls._resolve_id_to_name(f"{key[:-1]}_id", item, db) - if resolved_name: - resolved_list.append({"id": item, "name": resolved_name}) - else: - resolved_list.append({"id": item}) - else: - resolved_list.append(item) - resolved_properties[key] = resolved_list - else: - resolved_properties[key] = value - - resolved["properties"] = resolved_properties - - return resolved - - @classmethod - async def _format_primary_entity_with_properties( - cls, - entity_params: Dict[str, Any], - workspace_slug: Optional[str], - project_id: Optional[str], - db: Optional[Any], - tool_name: Optional[str] = None, - ) -> Dict[str, Any]: - """Format a primary entity (non-workitem) with a properties sub-dict. - - Mirrors workitem formatting, but is generic for entities like module, state, label, etc. - """ - entity_data: Dict[str, Any] = {} - properties: Dict[str, Any] = {} - - for key, value in entity_params.items(): - if key in ["name", "title"]: - # Entity display name - formatted_name = await cls._format_single_parameter(key, value, workspace_slug, project_id, db, tool_name) - # Unwrap {"name": "..."} to plain string for parity with work-items - if isinstance(formatted_name, dict) and "name" in formatted_name and len(formatted_name) == 1: - entity_data["name"] = formatted_name["name"] - else: - entity_data["name"] = formatted_name - elif key == "identifier" and tool_name and tool_name.startswith("projects_"): - # Keep identifier as plain string for project - try: - if isinstance(value, str): - entity_data["identifier"] = value - elif isinstance(value, dict) and "name" in value: - entity_data["identifier"] = value["name"] - else: - entity_data["identifier"] = str(value) - except Exception: - entity_data["identifier"] = value - elif key in ["pk", "id"] or ( - tool_name - and ( - (tool_name.startswith("projects_") and key in ["project_id"]) - or (tool_name.startswith("labels_") and key in ["label_id"]) - or (tool_name.startswith("states_") and key in ["state_id"]) - or (tool_name.startswith("modules_") and key in ["module_id"]) - or (tool_name.startswith("cycles_") and key in ["cycle_id"]) - ) - ): - # Normalize primary key/id into top-level id and avoid nesting for all entities - try: - normalized_id_str: Optional[str] = None - if isinstance(value, str): - normalized_id_str = value - elif isinstance(value, dict): - # Common nested shapes - nid = value.get("id") or value.get("pk") or value.get("name") - if isinstance(nid, dict): - nid = nid.get("id") or nid.get("pk") or nid.get("name") - if nid is not None: - normalized_id_str = str(nid) - else: - normalized_id_str = str(value) - - if normalized_id_str is not None: - entity_data["id"] = normalized_id_str - # If caller provided a name dict for the *_id param, preserve it - if not entity_data.get("name") and isinstance(value, dict) and value.get("name"): - entity_data["name"] = value.get("name") - # Best-effort: resolve entity display name by ID for primary entities - if db and not entity_data.get("name"): - try: - # Prefer specialized resolvers when available - if tool_name and tool_name.startswith("labels_"): - resolved_label = await cls._resolve_label_by_id(normalized_id_str) - if resolved_label and resolved_label.get("name"): - entity_data["name"] = resolved_label.get("name") - elif tool_name and tool_name.startswith("states_"): - # Resolve state by id for display name if helper exists - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_state_details_by_id - - st = await get_state_details_by_id(normalized_id_str) - if st and st.get("name"): - entity_data["name"] = st.get("name") - except Exception: - pass - - # Generic fallback using resolve_id_to_name for all entity types - if not entity_data.get("name"): - id_key = None - if isinstance(key, str) and key.endswith("_id"): - id_key = key - elif tool_name: - if tool_name.startswith("projects_"): - id_key = "project_id" - elif tool_name.startswith("labels_"): - id_key = "label_id" - elif tool_name.startswith("states_"): - id_key = "state_id" - elif tool_name.startswith("modules_"): - id_key = "module_id" - elif tool_name.startswith("cycles_"): - id_key = "cycle_id" - - if id_key: - resolved_name = await cls._resolve_id_to_name(id_key, normalized_id_str, db) - if resolved_name: - entity_data["name"] = resolved_name - except Exception: - pass - except Exception: - pass - else: - formatted_value = await cls._format_single_parameter(key, value, workspace_slug, project_id, db, tool_name) - properties[key] = formatted_value - - if properties: - entity_data["properties"] = properties - - return entity_data - - @classmethod - def _extract_entity_type_from_tool_name(cls, tool_name: str) -> str: - """Extract entity type from tool name (e.g., 'workitems_create' -> 'workitem').""" - # Handle special cases first - if tool_name.startswith("workitems_"): - return "workitem" - elif tool_name.startswith("epics_"): - return "workitem" # Epics are a type of work item - elif tool_name.startswith("projects_"): - return "project" - elif tool_name.startswith("cycles_"): - return "cycle" - elif tool_name.startswith("modules_"): - return "module" - elif tool_name.startswith("comments_"): - return "comment" - elif tool_name.startswith("pages_"): - return "page" - elif tool_name.startswith("labels_"): - return "label" - elif tool_name.startswith("states_"): - return "state" - elif tool_name.startswith("users_"): - return "user" - else: - # Generic extraction - take first part before underscore - parts = tool_name.split("_") - if len(parts) > 1: - entity = parts[0] - # Convert plural to singular for common cases - if entity.endswith("s") and entity not in ["issues", "users"]: - entity = entity[:-1] - return entity - return "unknown" - - @classmethod - def _group_parameters_by_entity(cls, params: Dict[str, Any], tool_name: Optional[str] = None) -> Dict[str, Dict[str, Any]]: - """Group parameters by their parent entity.""" - grouped: Dict[str, Dict[str, Any]] = {} - - # Extract entity type from tool name if available - entity_type_from_tool = None - if tool_name: - entity_type_from_tool = cls._extract_entity_type_from_tool_name(tool_name) - - for key, value in params.items(): - if key == "workspace_slug": - continue - - # Determine which entity this parameter belongs to - if key in [ - "name", - "title", - "identifier", - "description", - "description_html", - "priority", - "state", - "state_id", - "point", - "estimate_point", - "start_date", - "target_date", - "assignees", - "assignee_id", - "labels", - "label_id", - "issue_id", - "issues", - "workitems", - "status", - "lead", - "members", - "color", - "group", - ]: - # For creation tools, use the entity type from the tool for ALL parameters - if entity_type_from_tool: - entity = entity_type_from_tool - else: - # Fallback to workitem for backward compatibility - entity = "workitem" - elif key in ["project_id", "project"]: - entity = "project" - elif key == "pk": - # Route pk to the primary entity inferred from tool name (works across update ops) - # Also capture it under a special top-level slot later via flattening - entity = entity_type_from_tool or "unknown" - elif key in ["module_id", "module"]: - entity = "module" - elif key in ["cycle_id", "cycle"]: - entity = "cycle" - else: - # Default entity mapping - entity = cls._get_entity_key_for_parameter(key) - - if entity not in grouped: - grouped[entity] = {} - grouped[entity][key] = value - - return grouped - - @classmethod - async def _format_workitem_with_properties( - cls, workitem_params: Dict[str, Any], workspace_slug: Optional[str], project_id: Optional[str], db: Optional[Any] - ) -> Dict[str, Any]: - """Format workitem with its properties grouped together.""" - workitem_data = {} - properties: Dict[str, Any] = {} - for key, value in workitem_params.items(): - if key in ["name", "title"]: - # Main workitem name - workitem_data["name"] = value - elif key == "issue_id": - # Resolve issue_id to workitem name and other details - if cls._is_uuid_like(str(value)): - if db: - from pi.app.api.v1.helpers.plane_sql_queries import get_workitem_details_for_artifact - - try: - resolved_workitem = await get_workitem_details_for_artifact(str(value)) - if resolved_workitem: - workitem_data["name"] = resolved_workitem.get("name", "Unknown Work Item") - workitem_data["id"] = str(value) - # Add other workitem details if available - if "project_name" in resolved_workitem: - workitem_data["project"] = resolved_workitem["project_name"] - else: - workitem_data["name"] = "Unknown Work Item" - workitem_data["id"] = str(value) - except Exception as e: - log.error(f"Error resolving issue_id {value}: {e}") - workitem_data["name"] = "Unknown Work Item" - workitem_data["id"] = str(value) - else: - workitem_data["name"] = "Unknown Work Item" - workitem_data["id"] = str(value) - else: - # If not a UUID, treat as name - workitem_data["name"] = str(value) - elif key in ["description", "description_html"]: - workitem_data["description"] = value - elif key in ["priority", "state", "state_id", "point", "estimate_point", "start_date", "target_date", "assignees", "labels", "label_id"]: - # These go into properties - if key == "priority": - properties["priority"] = {"name": value} - elif key in ["state", "state_id"]: - # Try to resolve state - if cls._is_uuid_like(str(value)): - if db: - from pi.app.api.v1.helpers.plane_sql_queries import get_state_details_by_id - - resolved_state = await get_state_details_by_id(str(value)) - if resolved_state: - properties["state"] = {"id": resolved_state["id"], "name": resolved_state["name"], "group": resolved_state["group"]} - else: - properties["state"] = {"id": value, "name": "Unknown State"} - else: - properties["state"] = {"id": value, "name": "Unknown State"} - else: - resolved_entity = await cls._resolve_state(str(value), workspace_slug, project_id) - if resolved_entity: - properties["state"] = resolved_entity - else: - properties["state"] = {"name": str(value)} - elif key in ["point", "estimate_point"]: - properties["story_points"] = {"name": str(value)} - elif key == "start_date": - properties["start_date"] = {"name": str(value)} - elif key == "target_date": - properties["target_date"] = {"name": str(value)} - elif key == "assignees": - # Handle assignees list, resolve name/ID -> {id,name} - resolved_assignees: List[Dict[str, Any]] = [] - try: - if isinstance(value, list): - for assignee_val in value: - if isinstance(assignee_val, str) and cls._is_uuid_like(assignee_val): - resolved = await cls._resolve_user_by_id(assignee_val) - if resolved: - resolved_assignees.append({"id": resolved.get("id"), "name": resolved.get("name")}) - else: - resolved_assignees.append({"id": assignee_val}) - else: - resolved = await cls._resolve_user(str(assignee_val), workspace_slug, project_id) - if resolved: - resolved_assignees.append({"id": resolved.get("id"), "name": resolved.get("name")}) - else: - resolved_assignees.append({"name": str(assignee_val)}) - else: - assignee_val = value - if isinstance(assignee_val, str) and cls._is_uuid_like(assignee_val): - resolved = await cls._resolve_user_by_id(assignee_val) - if resolved: - resolved_assignees.append({"id": resolved.get("id"), "name": resolved.get("name")}) - else: - resolved_assignees.append({"id": assignee_val}) - else: - resolved = await cls._resolve_user(str(assignee_val), workspace_slug, project_id) - if resolved: - resolved_assignees.append({"id": resolved.get("id"), "name": resolved.get("name")}) - else: - resolved_assignees.append({"name": str(assignee_val)}) - except Exception as e: - log.error(f"Error resolving assignees in planning: {e}") - if isinstance(value, list): - resolved_assignees = [{"name": str(assignee)} for assignee in value] - else: - resolved_assignees = [{"name": str(value)}] - - properties["assignees"] = resolved_assignees - elif key == "labels": - # Handle labels list, resolve name -> {id,name,color} - resolved_labels: List[Dict[str, Any]] = [] - try: - if isinstance(value, list): - for label_val in value: - if isinstance(label_val, str) and cls._is_uuid_like(label_val): - resolved = await cls._resolve_label_by_id(label_val) - if resolved: - resolved_labels.append({ - "id": resolved.get("id"), - "name": resolved.get("name"), - "color": resolved.get("color"), - }) - else: - resolved_labels.append({"id": label_val}) - else: - # Try resolving by name - resolved = await cls._resolve_label(str(label_val), workspace_slug, project_id) - if resolved: - resolved_labels.append({ - "id": resolved.get("id"), - "name": resolved.get("name"), - "color": resolved.get("color"), - }) - else: - resolved_labels.append({"name": str(label_val)}) - else: - label_val = value - if isinstance(label_val, str) and cls._is_uuid_like(label_val): - resolved = await cls._resolve_label_by_id(label_val) - if resolved: - resolved_labels.append({"id": resolved.get("id"), "name": resolved.get("name"), "color": resolved.get("color")}) - else: - resolved_labels.append({"id": str(label_val)}) - else: - resolved = await cls._resolve_label(str(label_val), workspace_slug, project_id) - if resolved: - resolved_labels.append({"id": resolved.get("id"), "name": resolved.get("name"), "color": resolved.get("color")}) - else: - resolved_labels.append({"name": str(label_val)}) - except Exception as e: - log.error(f"Error resolving labels in planning: {e}") - if isinstance(value, list): - resolved_labels = [{"name": str(label)} for label in value] - else: - resolved_labels = [{"name": str(value)}] - - properties["labels"] = resolved_labels - elif key == "label_id": - # Normalize single label to labels array with full details - try: - resolved_label: Optional[Dict[str, Any]] = None - if isinstance(value, str) and cls._is_uuid_like(value): - resolved_label = await cls._resolve_label_by_id(value) - if not resolved_label: - resolved_label = {"id": value} - else: - resolved_label = await cls._resolve_label(str(value), workspace_slug, project_id) - if not resolved_label: - resolved_label = {"name": str(value)} - - if "labels" not in properties: - properties["labels"] = [] - properties["labels"].append({ - "id": resolved_label.get("id"), - "name": resolved_label.get("name"), - "color": resolved_label.get("color"), - }) - except Exception as e: - log.error(f"Error resolving label_id in planning: {e}") - if "labels" not in properties: - properties["labels"] = [] - properties["labels"].append({"name": str(value)}) - elif key == "assignee_id": - # Normalize single assignee to assignees array with full details - try: - resolved_user: Optional[Dict[str, Any]] = None - if isinstance(value, str) and cls._is_uuid_like(value): - resolved_user = await cls._resolve_user_by_id(value) - if not resolved_user: - resolved_user = {"id": value} - else: - resolved_user = await cls._resolve_user(str(value), workspace_slug, project_id) - if not resolved_user: - resolved_user = {"name": str(value)} - - if "assignees" not in properties: - properties["assignees"] = [] - properties["assignees"].append({ - "id": resolved_user.get("id"), - "name": resolved_user.get("name"), - }) - except Exception as e: - log.error(f"Error resolving assignee_id in planning: {e}") - if "assignees" not in properties: - properties["assignees"] = [] - properties["assignees"].append({"name": str(value)}) - - # Set default name if not provided - if "name" not in workitem_data: - workitem_data["name"] = "Untitled" - - # Add properties if any exist - if properties: - workitem_data["properties"] = properties - - return workitem_data - - @classmethod - async def _format_single_parameter( - cls, key: str, value: Any, workspace_slug: Optional[str], project_id: Optional[str], db: Optional[Any], tool_name: Optional[str] = None - ) -> Any: - """Format a single parameter value.""" - # Format the value based on type and resolve entities - if isinstance(value, list): - # Handle lists (e.g., assignees, workitems, labels) - formatted_list = [] - for item in value: - if isinstance(item, str): - # UUID-like list items: enrich with name/identifier when possible - if cls._is_uuid_like(item): - if key in ("issues", "workitems"): - # Resolve work-item unique key and name for display - if db: - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact - - details = await get_issue_identifier_for_artifact(item) - if details: - # Include id, name, and unique identifier (e.g., WEB-821) - formatted_list.append({ - "id": item, - "name": details.get("name"), - "identifier": details.get("identifier"), - }) - continue - except Exception: - pass - # Fallback: keep id only - formatted_list.append({"id": item}) - continue - - # Other UUID lists (assignees, labels, etc.): try resolving to a name - if db: - try: - resolved_name = await cls._resolve_id_to_name(key, item, db) - if resolved_name: - formatted_list.append({"id": item, "name": resolved_name}) - continue - except Exception: - pass - # Fallback to id-only when name not resolvable - formatted_list.append({"id": item}) - continue - - # Non-UUID strings: try entity resolution (by name) - resolved_entity = await cls._resolve_entity_for_parameter(key, item, workspace_slug, project_id) - if resolved_entity: - formatted_list.append(resolved_entity) - else: - formatted_list.append({"name": item}) - elif isinstance(item, dict) and "name" in item: - formatted_list.append(item) - elif isinstance(item, dict) and "id" in item: - # Preserve already structured id dicts - formatted_list.append(item) - else: - formatted_list.append({"name": str(item)}) - return formatted_list - - elif isinstance(value, dict): - # Handle dict values (already structured). Normalize pk/id dicts broadly, but avoid duplicating under properties. - try: - if key in ("pk", "id", "project_id", "module_id", "cycle_id", "state_id", "label_id", "issue_id"): - inner = value.get("id") or value.get("pk") or value.get("name") or value - if isinstance(inner, dict): - inner = inner.get("id") or inner.get("pk") or inner.get("name") or inner - if isinstance(inner, str) and cls._is_uuid_like(inner): - return {"id": inner} - except Exception: - pass - return value - - elif value is not None and value != "": - # Handle single values - if isinstance(value, str): - # For creation tools, don't resolve name parameters as existing entities - should_resolve_as_entity = True - if tool_name and tool_name.endswith("_create") and key in ["name", "title"]: - should_resolve_as_entity = False - - # Try to resolve as entity first (if appropriate) - if should_resolve_as_entity: - resolved_entity = await cls._resolve_entity_for_parameter(key, value, workspace_slug, project_id) - if resolved_entity: - return resolved_entity - - # If not an entity, check if it's a UUID that needs name/identifier resolution - if db and cls._is_uuid_like(value): - # Special handling for single work-item IDs - if key in ("issue_id", "workitem_id"): - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact - - details = await get_issue_identifier_for_artifact(value) - if details: - return { - "id": value, - "name": details.get("name"), - "identifier": details.get("identifier"), - } - except Exception: - pass - resolved_name = await cls._resolve_id_to_name(key, value, db) - if resolved_name: - return {"id": value, "name": resolved_name} - else: - return {"name": value} - else: - return {"name": value} - else: - return {"name": str(value)} - - return None - - @classmethod - def _get_entity_key_for_parameter(cls, param_key: str) -> str: - """ - Map parameter keys to entity type names for consistent API responses. - - Args: - param_key: The parameter key from the tool call - - Returns: - Entity type name for the parameter - """ - # Direct entity type mappings - entity_mappings = { - # ID parameters - "project_id": "project", - "module_id": "module", - "cycle_id": "cycle", - "state_id": "state", - "label_id": "label", - "user_id": "user", - "assignee_id": "assignee", - "issue_id": "workitem", # Map issue_id to workitem - # List parameters - "assignees": "assignees", - "labels": "labels", - "workitems": "workitems", - "issues": "workitems", # Alias - # Workitem-specific parameters - "name": "workitem", # For workitem name - "title": "workitem", # Alternative for workitem title - "description": "description", - "description_html": "description", - # Common parameters - "priority": "priority", - "start_date": "start_date", - "target_date": "target_date", - "point": "story_points", - "estimate_point": "estimate", - # Status/state - "state": "state", - "status": "status", - } - - # Check direct mappings first - if param_key in entity_mappings: - return entity_mappings[param_key] - - # For _id parameters, extract the entity type - if param_key.endswith("_id"): - entity_type = param_key.replace("_id", "") - return entity_type - - # Fallback to the parameter key itself (cleaned up) - return param_key.replace("_", " ").lower() - - @classmethod - def _is_uuid_like(cls, value: Any) -> bool: - """Check if a value looks like a UUID.""" - if not isinstance(value, str): - return False - - # Simple UUID pattern check (8-4-4-4-12 format) - uuid_pattern = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE) - return bool(uuid_pattern.match(value)) - - @classmethod - async def _resolve_id_to_name(cls, param_key: str, param_value: str, db: Optional[Any]) -> Optional[str]: - """ - Resolve an ID to a name using database queries. - - Args: - param_key: The parameter key (e.g., 'project_id') - param_value: The parameter value (the ID) - db: Database session - - Returns: - Resolved name or None if not resolvable - """ - try: - from pi.app.api.v1.helpers.plane_sql_queries import resolve_id_to_name - - # Extract entity type from parameter key - entity_type = param_key.replace("_id", "").lower() - - # Resolve the ID to a name - resolved_name = await resolve_id_to_name(entity_type, param_value) - - if resolved_name: - return resolved_name - else: - return None - - except Exception as e: - log.error(f"Error resolving ID to name for {param_key}={param_value}: {e}") - return None - - @classmethod - async def _resolve_entity_for_parameter( - cls, param_key: str, param_value: str, workspace_slug: Optional[str], project_id: Optional[str] - ) -> Optional[Dict[str, Any]]: - """ - Resolve a parameter value to entity information if it's an entity reference. - - Args: - param_key: Parameter key (e.g., 'module_id', 'assignees', 'labels') - param_value: Parameter value (name or ID) - workspace_slug: Workspace slug for context - project_id: Project ID for context - - Returns: - Dict with entity information or None if not resolvable - """ - try: - # Skip if already a UUID - these are handled separately - if cls._is_uuid_like(param_value): - return None - - # Map parameter keys to search functions - entity_resolvers = { - "project_id": cls._resolve_project, - "module_id": cls._resolve_module, - "cycle_id": cls._resolve_cycle, - "state_id": cls._resolve_state, - "label_id": cls._resolve_label, - "assignee_id": cls._resolve_user, - "user_id": cls._resolve_user, - "issue_id": cls._resolve_workitem, # Add issue_id resolver - # Handle list parameters - "assignees": cls._resolve_user, - "labels": cls._resolve_label, - "workitems": cls._resolve_workitem, - "issues": cls._resolve_workitem, # Alias for workitems - } - - # Determine which resolver to use - resolver = None - if param_key in entity_resolvers: - resolver = entity_resolvers[param_key] - elif param_key.endswith("_id"): - # Try to infer from the parameter name - entity_type = param_key.replace("_id", "") - if entity_type in ["module", "cycle", "state", "label", "user", "project", "workitem", "issue"]: - resolver = entity_resolvers.get(f"{entity_type}_id") - - if resolver: - return await resolver(param_value, workspace_slug, project_id) - - return None - - except Exception as e: - log.error(f"Error resolving entity for {param_key}={param_value}: {e}") - return None - - @classmethod - async def _resolve_project(cls, name: str, workspace_slug: Optional[str], project_id: Optional[str]) -> Optional[Dict[str, Any]]: - """Resolve project by name. - - Preference order: - 1) Exact (case-insensitive) name match if unique - 2) Single result - 3) Otherwise, return None to allow upstream disambiguation via ask_for_clarification - """ - try: - from pi.app.api.v1.helpers.plane_sql_queries import search_project_by_name - - results = await search_project_by_name(name, workspace_slug) - if not results: - return None - - # If list, prefer exact case-insensitive match - try: - matches = [p for p in results if isinstance(p.get("name"), str) and p["name"].lower() == str(name).lower()] - except Exception: - matches = [] - - chosen = None - if len(matches) == 1: - chosen = matches[0] - elif len(results) == 1: - chosen = results[0] - - if chosen: - return { - "id": chosen["id"], - "name": chosen["name"], - "workspace_id": chosen.get("workspace_id"), - "type": "project", - } - return None - except Exception as e: - log.error(f"Error resolving project '{name}': {e}") - return None - - @classmethod - async def _resolve_module(cls, name: str, workspace_slug: Optional[str], project_id: Optional[str]) -> Optional[Dict[str, Any]]: - """Resolve module by name.""" - try: - from pi.app.api.v1.helpers.plane_sql_queries import search_module_by_name - - result = await search_module_by_name(name, project_id, workspace_slug) - if result: - return {"id": result["id"], "name": result["name"], "project_id": result.get("project_id"), "type": "module"} - return None - except Exception as e: - log.error(f"Error resolving module '{name}': {e}") - return None - - @classmethod - async def _resolve_cycle(cls, name: str, workspace_slug: Optional[str], project_id: Optional[str]) -> Optional[Dict[str, Any]]: - """Resolve cycle by name.""" - try: - from pi.app.api.v1.helpers.plane_sql_queries import search_cycle_by_name - - result = await search_cycle_by_name(name, project_id, workspace_slug) - if result: - return {"id": result["id"], "name": result["name"], "project_id": result.get("project_id"), "type": "cycle"} - return None - except Exception as e: - log.error(f"Error resolving cycle '{name}': {e}") - return None - - @classmethod - async def _resolve_state(cls, name: str, workspace_slug: Optional[str], project_id: Optional[str]) -> Optional[Dict[str, Any]]: - """Resolve state by name with improved reliability.""" - try: - # First try the search method with better error handling - from pi.app.api.v1.helpers.plane_sql_queries import search_state_by_name - - result = await search_state_by_name(name, project_id, workspace_slug) - if result: - return { - "id": result["id"], - "name": result["name"], - "group": result.get("group"), - "project_id": result.get("project_id"), - "type": "state", - } - - # If search fails, log the attempt for debugging - log.warning(f"State search failed for name='{name}', project_id='{project_id}', workspace_slug='{workspace_slug}'") - return None - - except Exception as e: - log.error(f"Error resolving state '{name}': {e}") - return None - - @classmethod - async def _resolve_label(cls, name: str, workspace_slug: Optional[str], project_id: Optional[str]) -> Optional[Dict[str, Any]]: - """Resolve label by name.""" - try: - from pi.app.api.v1.helpers.plane_sql_queries import search_label_by_name - - result = await search_label_by_name(name, project_id, workspace_slug) - if result: - return { - "id": result["id"], - "name": result["name"], - "color": result.get("color"), - "project_id": result.get("project_id"), - "type": "label", - } - return None - except Exception as e: - log.error(f"Error resolving label '{name}': {e}") - return None - - @classmethod - async def _resolve_label_by_id(cls, label_id: str) -> Optional[Dict[str, Any]]: - """Resolve label by ID to include name and color.""" - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_label_details_for_artifact - - details = await get_label_details_for_artifact(label_id) - if details: - return {"id": str(details.get("id", label_id)), "name": details.get("name"), "color": details.get("color"), "type": "label"} - return None - except Exception as e: - log.error(f"Error resolving label by id '{label_id}': {e}") - return None - - @classmethod - async def _resolve_user(cls, name: str, workspace_slug: Optional[str], project_id: Optional[str]) -> Optional[Dict[str, Any]]: - """Resolve user by display name.""" - try: - from pi.app.api.v1.helpers.plane_sql_queries import search_user_by_name - - result = await search_user_by_name(name, workspace_slug) - if result: - if len(result) > 1: - log.warning(f"Multiple users found for name '{name}': {len(result)} matches. Using first match: {result[0]["display_name"]}") - - # Use the first result - user = result[0] - return {"id": user["id"], "name": user["display_name"], "email": user.get("email"), "type": "user"} - return None - except Exception as e: - log.error(f"Error resolving user '{name}': {e}") - return None - - @classmethod - async def _resolve_user_by_id(cls, user_id: str) -> Optional[Dict[str, Any]]: - """Resolve user by ID to include at least display name.""" - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_user_name - - display_name = await get_user_name(user_id) - if display_name: - return {"id": user_id, "name": display_name, "type": "user"} - return None - except Exception as e: - log.error(f"Error resolving user by id '{user_id}': {e}") - return None - - @classmethod - async def _resolve_workitem(cls, name: str, workspace_slug: Optional[str], project_id: Optional[str]) -> Optional[Dict[str, Any]]: - """Resolve workitem by name.""" - try: - from pi.app.api.v1.helpers.plane_sql_queries import search_workitem_by_name - - result = await search_workitem_by_name(name, project_id, workspace_slug) - if result: - return {"id": result["id"], "name": result["name"], "project_id": result.get("project_id"), "type": "workitem"} - return None - except Exception as e: - log.error(f"Error resolving workitem '{name}': {e}") - return None - - @classmethod - def format_for_streaming(cls, action_summary: Dict[str, Any], message_id: str) -> str: - """ - Format action summary for streaming response with unique prefix. - - Args: - action_summary: Action summary dictionary - message_id: ID of the message containing this action - - Returns: - Formatted string for streaming - """ - log.info(f"Action summary: {action_summary}") - if not action_summary: - return "" - - # Extract action verb, artifact type, and sub-type from tool_name - tool_name = action_summary.get("tool_name", "") - action_verb, artifact_type, artifact_sub_type = cls._extract_action_and_artifact_type(tool_name) - - # Use artifact_id from action_summary if available, otherwise extract from parameters - artifact_id = action_summary.get("artifact_id") - if not artifact_id: - parameters = action_summary.get("parameters", {}) - artifact_id = cls._extract_artifact_id(parameters, artifact_type) - - # Get parameters from action_summary - parameters = action_summary.get("parameters", {}) - - # Use the unified normalizer for consistent structure across all pipelines - from pi.services.actions.artifacts.utils import normalize_parameters_structure - - flattened_params = normalize_parameters_structure(parameters, flatten_entities=True) - - # Handle sub-type for add/remove operations - if artifact_sub_type: - flattened_params["artifact_sub_type"] = artifact_sub_type - # Check if we need to rename "issues" key - if "properties" in flattened_params: - properties = flattened_params["properties"] - if isinstance(properties, dict) and "issues" in properties: - properties[artifact_sub_type] = properties.pop("issues") - - # Include pk only if it was present in the original tool args - try: - raw_args = action_summary.get("raw_args", {}) or {} - if isinstance(raw_args, dict) and "pk" in raw_args: - pk_val = raw_args.get("pk") - # Unwrap to a UUID-like string if possible - candidate = None - if isinstance(pk_val, str): - candidate = pk_val - elif isinstance(pk_val, dict): - # Safely unwrap nested id-like values while keeping types explicit for mypy - inner_candidate = pk_val.get("id") or pk_val.get("pk") or pk_val.get("name") - if isinstance(inner_candidate, dict): - nested = inner_candidate.get("id") or inner_candidate.get("pk") or inner_candidate.get("name") - candidate = nested if isinstance(nested, str) else None - elif isinstance(inner_candidate, str): - candidate = inner_candidate - else: - candidate = None - # Fallback to entity id from parameters - if not candidate and isinstance(parameters, dict): - ent = parameters.get(artifact_type) - if isinstance(ent, dict): - candidate = ent.get("id") - # If candidate looks like UUID, add pk - if isinstance(candidate, str) and cls._is_uuid_like(candidate): - flattened_params["pk"] = {"id": candidate} - except Exception: - pass - - # Create frontend-friendly version matching requested format - frontend_action_summary = { - "action": action_verb, - "artifact_type": artifact_type, - "artifact_id": artifact_id, - "tool_name": tool_name, - "parameters": flattened_params, - "message_id": message_id, - } - - # Add artifact_sub_type for add/remove operations - if artifact_sub_type: - frontend_action_summary["artifact_sub_type"] = artifact_sub_type - - # Optionally include sequence if present in the action_summary (planning stage) - try: - if isinstance(action_summary, dict) and "sequence" in action_summary: - frontend_action_summary["sequence"] = action_summary["sequence"] - except Exception: - pass - - # Convert to JSON string with unique prefix - import json - - return f"πspecial actions blockπ: {json.dumps(frontend_action_summary, default=str)}" - - @classmethod - def _extract_action_and_artifact_type(cls, tool_name: str) -> tuple[str, str, Optional[str]]: - """Extract action verb, artifact type, and sub-type from tool name.""" - if not tool_name or "_" not in tool_name: - return "unknown", "unknown", None - - # Special case: relation operations are updates, not creates - if "_create_relation" in tool_name: - parts = tool_name.split("_") - artifact_type = parts[0] if parts else "unknown" - # Convert artifact type to singular form - if artifact_type.endswith("s") and len(artifact_type) > 1: - artifact_type = artifact_type[:-1] # "workitems" -> "workitem" - return "update", artifact_type, None - - parts = tool_name.split("_") - if len(parts) >= 2: - artifact_type = parts[0] # e.g., "workitems_create" -> "workitems" - action_verb = parts[1] if len(parts) > 1 else "unknown" # e.g., "create" - - # Extract sub-type for add/remove operations - sub_type = None - if action_verb in ["add", "remove"] and len(parts) > 2: - # e.g., "modules_add_work_items" -> sub_type = "work_items" - sub_type = "_".join(parts[2:]) - # Normalize sub_type to singular form - if sub_type == "work_items": - sub_type = "workitem" - elif sub_type == "work_item": - sub_type = "workitem" - - # Convert artifact type to singular form - if artifact_type.endswith("s") and len(artifact_type) > 1: - artifact_type = artifact_type[:-1] # "workitems" -> "workitem" - - return action_verb, artifact_type, sub_type - else: - return "unknown", "unknown", None - - @classmethod - def _extract_artifact_id(cls, parameters: Dict[str, Any], artifact_type: str) -> str: - """Generate a dummy artifact ID for the artifacts table (to be implemented).""" - # TODO: This will be replaced with actual artifact ID generation when artifacts table is implemented - import uuid - - return str(uuid.uuid4()) diff --git a/apps/pi/pi/services/chat/helpers/agent_executor.py b/apps/pi/pi/services/chat/helpers/agent_executor.py deleted file mode 100644 index 5fc34a4fb4..0000000000 --- a/apps/pi/pi/services/chat/helpers/agent_executor.py +++ /dev/null @@ -1,791 +0,0 @@ -"""Agent execution logic for single and multi-agent scenarios.""" - -import ast -import asyncio -import re -from collections.abc import AsyncIterator -from typing import Any -from typing import Dict -from typing import List -from typing import Tuple -from typing import Union -from typing import cast - -from langchain_core.messages import AIMessage -from langchain_core.messages import BaseMessage -from langchain_core.messages import HumanMessage -from langchain_core.messages import SystemMessage -from langchain_core.messages import ToolMessage - -from pi import logger -from pi.app.models.enums import ExecutionStatus -from pi.app.models.enums import FlowStepType -from pi.app.models.enums import MessageMetaStepType -from pi.core.db.plane_pi.lifecycle import get_streaming_db_session -from pi.services.chat.prompts import multi_tool_system_prompt -from pi.services.chat.utils import StandardAgentResponse -from pi.services.chat.utils import format_conversation_history -from pi.services.chat.utils import get_current_timestamp_context -from pi.services.chat.utils import mask_uuids_in_text -from pi.services.chat.utils import standardize_flow_step_content -from pi.services.retrievers.pg_store.json_serializer import sanitize_execution_data -from pi.services.retrievers.pg_store.message import upsert_message_flow_steps as _upsert_message_flow_steps -from pi.services.schemas.chat import Agents - -log = logger.getChild(__name__) - - -async def execute_single_agent( - chatbot_instance, - agent, - sub_query, - user_meta, - message_id, - workspace_id, - project_id, - conversation_history, - user_id, - chat_id, - query_flow_store, - parsed_query, - query_id, - step_order, - workspace_in_context, - db, - preset_tables=None, - preset_sql_query=None, - preset_placeholders=None, -): - """Execute a single agent and prepare response stream.""" - if not workspace_in_context: - return chatbot_instance.handle_generic_query_stream( - sub_query, - user_id, - conversation_history, - user_meta, - non_plane=True, - include_user_context_on_followup=bool(conversation_history), - ), None - - if agent == Agents.GENERIC_AGENT: - return chatbot_instance.handle_generic_query_stream(sub_query, user_id, conversation_history, user_meta), None - - agent_result = await chatbot_instance.handle_agent_query( - db, - agent, - sub_query, - user_meta, - message_id, - workspace_id, - project_id, - conversation_history, - user_id, - chat_id, - query_flow_store, - is_multi_agent=False, - preset_tables=preset_tables, - preset_sql_query=preset_sql_query, - preset_placeholders=preset_placeholders, - ) - - # Define response variable with proper typing upfront - response: Union[str, Dict[str, Any]] - - if isinstance(agent_result, tuple): - intermediate_results, response = agent_result - else: - intermediate_results = None - response = agent_result - responses: List[Tuple[str, str, Union[str, Dict[str, Any]]]] = [(agent, sub_query, response)] - formatted_responses_result = StandardAgentResponse.format_responses(responses, chat_id, query_flow_store) - - # Check if responses contain errors - if so, return error directly without LLM rewriting - if isinstance(formatted_responses_result, dict) and formatted_responses_result.get("has_errors"): - # Extract clean error message from the response - error_message = StandardAgentResponse.extract_results(response) - if not error_message: - error_message = "An error occurred while retrieving data. Please try again later." - - # Clean up any markdown formatting from SQL agent - error_message = error_message.strip() - if error_message.startswith("- **Result**: "): - error_message = error_message.replace("- **Result**: ", "") - - return chatbot_instance._create_simple_stream(error_message), None - - # Extract formatted string for normal flow - formatted_responses = formatted_responses_result.get("formatted") if isinstance(formatted_responses_result, dict) else formatted_responses_result - formatted_history = format_conversation_history(conversation_history) - # Sanitize execution_data to ensure JSON serializability - sanitized_execution_data = sanitize_execution_data(intermediate_results or {}) - - flow_steps = [ - { - "step_order": step_order, - "step_type": FlowStepType.TOOL.value, - "tool_name": agent.name, - "content": standardize_flow_step_content(response, FlowStepType.TOOL), - "execution_data": sanitized_execution_data, - } - ] - try: - async with get_streaming_db_session() as _subdb: - flow_step_result = await asyncio.wait_for( - _upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=flow_steps, - db=_subdb, - ), - timeout=2.0, - ) - except asyncio.TimeoutError: - log.warning(f"Timed out recording flow step for message {query_id}; continuing") - flow_step_result = {"message": "error", "error": "timeout"} - except Exception as e: - log.warning(f"Failed to record flow step for message {query_id}: {e}") - flow_step_result = {"message": "error", "error": str(e)} - - if flow_step_result["message"] != "success": - # Log the error but continue - flow step logging is not critical for user experience - log.warning(f"Failed to record flow step for message {query_id}: {flow_step_result.get("error", "Unknown error")}") - - return chatbot_instance.combined_response_stream( - parsed_query, - formatted_responses, - formatted_history, - user_meta, - user_id, - workspace_in_context=workspace_in_context, - ), None - - -async def execute_multi_agents( - chatbot_instance, - selected_agents, - user_meta, - workspace_id, - workspace_slug, - project_id, - conversation_history, - enhanced_conversation_history, - user_id, - chat_id, - query_flow_store, - parsed_query, - query_id, - step_order, - db, - original_query, - reasoning_container=None, - workspace_in_context: bool | None = None, -) -> AsyncIterator[str]: - """Execute multiple agents using LangChain tool calling for orchestration with real-time streaming.""" - - log.info(f"ChatID: {chat_id} - Starting multi-agent execution with {len(selected_agents)} agent(s): {[agent.agent for agent in selected_agents]}") - - # Reset shared state for this query - chatbot_instance.vector_search_issue_ids = [] - chatbot_instance.vector_search_page_ids = [] - # Reset stored agent responses for clean state - chatbot_instance.agent_responses = {} - - # Get tools for selected agents - log.info(f"ChatID: {chat_id} - Fetching tools for selected agents") - tools = await chatbot_instance._get_selected_tools( - db, - selected_agents, - user_meta, - workspace_id, - workspace_slug, - project_id, - user_id, - chat_id, - query_flow_store, - conversation_history, - query_id, - ) - - if not tools: - log.error(f"ChatID: {chat_id} - No valid tools found for selected agents") - # Finalize with a user-friendly message so caller can persist - yield "πspecial reasoning blockπ: 📝 Generating final response...\n\n" - yield "An unexpected error occurred. Please try again later." - return - - custom_prompt = ( - "⚠️ IMPORTANT: The router has suggested the following tools, but you MUST override " - "this selection if a more specialized tool is available.\n\n" - "Suggested tools and their queries: \n" - ) - # Map agent names to actual tool names to match bound tools - agent_to_tool_map = { - "plane_structured_database_agent": "structured_db_tool", - "plane_vector_search_agent": "vector_search_tool", - "plane_pages_agent": "pages_search_tool", - "plane_docs_agent": "docs_search_tool", - "generic_agent": "generic_query_tool", - } - tool_entries = [] - for agent in selected_agents: - tool_name = agent_to_tool_map.get(agent.agent, agent.agent) - tool_entries.append(f"Tool Name: {tool_name};\nTool Query: {agent.query}") - custom_prompt += "\n\n".join(tool_entries) - # Inject enhanced conversation history if available to guide orchestration - try: - if enhanced_conversation_history and isinstance(enhanced_conversation_history, str) and enhanced_conversation_history.strip(): - custom_prompt += f"\n\n**CONVERSATION HISTORY & ACTION CONTEXT:**\n{enhanced_conversation_history}\n" - except Exception: - pass - - # Provide PROJECT/USER/TIME context to the tool planner - context_block = "" - try: - # Project scoping (project chat) - if project_id: - context_block += f"\n\n**🔥 PROJECT CONTEXT (CRITICAL):**\nProject ID: {project_id}\n\n**IMPORTANT SCOPING RULES:**\n- This is a PROJECT-LEVEL chat - ALL operations are scoped to THIS PROJECT ONLY\n- When the request mentions 'current cycle', 'current module', 'work items', etc. - it means ONLY within THIS PROJECT\n- Use this project_id for ALL tools that accept project_id parameter\n- DO NOT query across all projects - scope everything to THIS specific project\n- User refers to 'this project'/'the project'/'current project' = use this project_id" # noqa: E501 - else: - # Workspace-level context (no specific project) - context_block += f"\n\n**🌐 WORKSPACE CONTEXT (CRITICAL):**\nWorkspace ID: {workspace_id}\n\n**IMPORTANT SCOPING RULES:**\n- This is a WORKSPACE-LEVEL chat - queries can span MULTIPLE PROJECTS\n- When the request mentions 'last cycle', 'this cycle', 'work items', etc. WITHOUT specifying a project - it could be in ANY project\n- Use list_member_projects (without limit or with high limit) to get ALL projects in the workspace\n- Then iterate through projects to find relevant cycles/modules/work-items\n- CRITICAL: Do NOT limit to 1 project unless the user specifically names or refers to a specific project" # noqa: E501 - - # User context - if user_id: - context_block += "\n**USER CONTEXT:**\n" f"User ID: {user_id}\n" "- Use this to resolve pronouns like 'me', 'my', 'assigned to me'.\n" - # Date/time context - dt_ctx = None - try: - dt_ctx = await get_current_timestamp_context(user_id) - except Exception: - dt_ctx = None - if dt_ctx: - context_block += f"\n{dt_ctx}\n" - except Exception: - # Non-fatal; continue without context block if any error occurs - pass - - # CRITICAL: Inject clarification context if this is a follow-up to ask_for_clarification - if user_meta and isinstance(user_meta, dict) and user_meta.get("clarification_context"): - clar_ctx = user_meta["clarification_context"] - original_query_text = clar_ctx.get("original_query") - reason = clar_ctx.get("reason") - disambig_options = clar_ctx.get("disambiguation_options") or [] - answer_text = clar_ctx.get("answer_text") - - custom_prompt += "\n\n**CLARIFICATION CONTEXT:**\n" - # CRITICAL: Include the original query to maintain full context - if original_query_text: - custom_prompt += f"Original user request: {original_query_text}\n" - if reason: - custom_prompt += f"Clarification reason: {reason}\n" - if disambig_options: - custom_prompt += "The user was previously shown these options:\n" - for idx, opt in enumerate(disambig_options, 1): - if isinstance(opt, dict): - opt_id = opt.get("id") - opt_name = opt.get("name") or opt.get("display_name") or "" - opt_identifier = opt.get("identifier") or "" - opt_email = opt.get("email") or "" - - # Format based on what fields are available - if opt_email: - # User entity - custom_prompt += f" {idx}. {opt_name} ({opt_email}) → UUID: {opt_id}\n" - elif opt_identifier: - # Project/workitem entity - custom_prompt += f" {idx}. {opt_name} (Identifier: {opt_identifier}) → UUID: {opt_id}\n" - else: - # Generic entity - custom_prompt += f" {idx}. {opt_name} → UUID: {opt_id}\n" - if answer_text: - custom_prompt += f"\nUser's clarification answer: {answer_text}\n" - custom_prompt += "\nIMPORTANT: The current user message is a clarification response to the original request above. Use the clarification answer to resolve missing information and continue with the ORIGINAL request, not as a new standalone request.\n" # noqa E501 - - # Bind tools to the LLM - llm_with_tools = chatbot_instance.tool_llm.bind_tools(tools) - - # Set tracking context on the bound LLM instance - llm_with_tools.set_tracking_context(query_id, db, MessageMetaStepType.TOOL_ORCHESTRATION, chat_id=str(chat_id)) - - # Track execution - tool_flow_steps: List[Dict[str, Any]] = [] - current_step = step_order - messages: List[BaseMessage] = [] - responses: List[Tuple[str, str, Union[str, Dict[str, Any]]]] = [] - - messages.append(SystemMessage(content=multi_tool_system_prompt)) - - # Prepend context at the top of the prompt for better LLM comprehension - if context_block.strip(): - custom_prompt = f"{context_block}\n\n{custom_prompt}" - - try: - query_to_use = parsed_query - messages.append(HumanMessage(content=custom_prompt + f"\n\nUser Query: {query_to_use}")) - - # Record the tool orchestration context (enhanced conversation history) before any tool planning/execution - try: - if enhanced_conversation_history and isinstance(enhanced_conversation_history, str) and enhanced_conversation_history.strip(): - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL.value, - "tool_name": "tool_orchestration_context", - "content": "Context used for tool orchestration", - "execution_data": {"enhanced_conversation_history": enhanced_conversation_history}, - "is_planned": False, - "is_executed": False, - }) - current_step += 1 - except Exception: - pass - # log.info("************************************************") - # log.info(f"ChatID: {chat_id} - multi-agent custom prompt: {custom_prompt}") - # log.info("************************************************") - # Log what's sent to the tool selection LLM - log.info(f"ChatID: {chat_id} - Tool selection LLM input - Query: {query_to_use}") - log.info(f"ChatID: {chat_id} - Tool selection LLM input - Available tools: {[t.name for t in tools]}") - - # Yield initial status - reasoning_chunk = "🤖 Retrieving information...\n\n" - if reasoning_container is not None: - reasoning_container["content"] += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - - # Initial invocation of LLM with tools - llm_response = await chatbot_instance._tool_orchestration_llm_call(llm_with_tools, messages) - - # Handle failure case - if llm_response == "TOOL_ORCHESTRATION_FAILURE": - # Persist any steps captured so far - if tool_flow_steps: - try: - async with get_streaming_db_session() as _subdb: - await _upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=tool_flow_steps, - db=_subdb, - ) - except Exception as _e: - log.warning(f"ChatID: {chat_id} - Failed to persist tool flow steps on failure: {_e}") - # Finalize with a short fallback message so caller can persist - yield "πspecial reasoning blockπ: 📝 Generating final response...\n\n" - yield "An unexpected error occurred. Please try again later." - return - - ai_message = cast(AIMessage, llm_response) - messages.append(ai_message) - - if not ai_message.tool_calls: - log.info(f"ChatID: {chat_id} - No tool calls found in the initial LLM response. {ai_message.content}") - responses.append((Agents.GENERIC_AGENT, query_to_use, ai_message.content)) # type: ignore - else: - # Log the tool calls selected by the LLM - tool_calls_summary = [(tc["name"], tc.get("args", {})) for tc in ai_message.tool_calls] - log.info(f"ChatID: {chat_id} - Tool selection LLM chose {len(ai_message.tool_calls)} tool(s): {tool_calls_summary}") - - # Process tool calls iteratively - while ai_message.tool_calls: - # Execute all tool calls - tool_messages = [] - for tool_call in ai_message.tool_calls: - tool_name = tool_call["name"] - tool_args = tool_call["args"] - tool_id = tool_call["id"] - # Intercept clarification requests and short-circuit - if tool_name == "ask_for_clarification": - log.info(f"ChatID: {chat_id} - Clarification requested by LLM: {tool_args.get("reason", "No reason provided")}") - try: - # Execute the clarification tool to get payload - tool_to_execute = next((t for t in tools if t.name == tool_name), None) - result = await tool_to_execute.ainvoke(tool_args) if tool_to_execute else "{}" - except Exception as e: - log.error(f"ChatID: {chat_id} - Clarification tool failed: {e}") - result = "{}" - - # Persist flow step with clarification_pending - try: - from pi.services.chat.utils import standardize_flow_step_content as _std - - payload = str(result) - clarification_execution_data = { - "args": tool_args, - "clarification_pending": True, - "clarification_payload": payload, - # Store planning context to avoid re-routing on follow-up - "selected_categories": [agent.agent for agent in selected_agents], - "bound_tool_names": [t.name for t in tools], - # CRITICAL: Store the original query so we can reconstruct full context on clarification follow-up - "original_query": original_query, - } - - # Sanitize execution_data to ensure JSON serializability - sanitized_clarification_data = sanitize_execution_data(clarification_execution_data) - - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL, - "tool_name": "ask_for_clarification", - "content": _std(payload, FlowStepType.TOOL), - "execution_data": sanitized_clarification_data, - "is_planned": False, - "is_executed": False, - "execution_success": ExecutionStatus.PENDING, - }) - current_step += 1 - except Exception: - pass - - # Persist immediately since we return right after streaming - try: - async with get_streaming_db_session() as _subdb: - flow_steps_result = await _upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=tool_flow_steps, - db=_subdb, - ) - if flow_steps_result.get("message") != "success": - log.warning("Failed to record clarification flow step") - except Exception as e: - log.error(f"ChatID: {chat_id} - Failed to persist clarification flow step: {e}") - - # Stream clarification block and stop orchestration - try: - yield f"πspecial clarification blockπ: {result}\n" - except Exception: - yield f"πspecial clarification blockπ: {str(result)}\n" - return - # Yield tool execution start (robust to different arg schemas) - if tool_name != "generic_query_tool": - try: - from pi.services.chat.helpers.tool_utils import format_tool_query_for_display - - user_friendly_tool_name = chatbot_instance._tool_name_shown_to_user(tool_name) - # Build a readable query snippet depending on tool schema - display_arg = format_tool_query_for_display(tool_name, tool_args, user_query=query_to_use) - masked_display = mask_uuids_in_text(display_arg or (query_to_use or "the request")) - reasoning_chunk = f"🔧 Performing {user_friendly_tool_name.lower()} to pull details about: {masked_display}\n\n" - except Exception: - user_friendly_tool_name = chatbot_instance._tool_name_shown_to_user(tool_name) - reasoning_chunk = f"🔧 Performing {user_friendly_tool_name.lower()}\n\n" - if reasoning_container is not None: - reasoning_container["content"] += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - - # Find and execute the tool - tool_to_execute = next((t for t in tools if t.name == tool_name), None) - if tool_to_execute: - log.info(f"ChatID: {chat_id} - Executing tool: {tool_name} with args: {str(tool_args)[:100]}") - try: - # Execute the tool directly; endpoint-level SSE heartbeat will keep the stream alive - tool_result = await tool_to_execute.ainvoke(tool_args) - - # Log brief output for observability - result_preview = str(tool_result)[:200] if tool_result else "None" - log.info(f"ChatID: {chat_id} - Tool {tool_name} result preview: {result_preview}") - log.debug(f"ChatID: {chat_id} - Tool {tool_name} completed successfully") - - # Yield tool completion and result - if not tool_name == "generic_query_tool": - user_friendly_tool_name = chatbot_instance._tool_name_shown_to_user(tool_name) - reasoning_chunk = f"✅ {user_friendly_tool_name} execution completed\n\n" - if reasoning_container is not None: - reasoning_container["content"] += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - # yield f"📊 Result: {str(tool_result)[:200]}{'...' if len(str(tool_result)) > 200 else ''}\n\n" - - # Record the tool execution with standardized execution_data format - execution_data: Dict[str, Any] = { - "tool_args": tool_args, - "tool_name": tool_name, - "execution_status": "success", - # "timestamp": await get_current_timestamp_context(user_id), - } - - stored_response = chatbot_instance.agent_responses.get(tool_name) - - if tool_name == "structured_db_tool": - # For structured_db_tool, include full intermediate_results for SQL debugging - if stored_response and "intermediate_results" in stored_response: - execution_data["intermediate_results"] = stored_response["intermediate_results"] - # Extract key debugging info to top level for easier access - ir = stored_response["intermediate_results"] - execution_data["sql_query"] = ir.get("generated_sql", "") - execution_data["final_query"] = ir.get("final_query", "") - execution_data["relevant_tables"] = ir.get("relevant_tables", []) - execution_data["tool_type"] = "database_query" - - elif tool_name in ["vector_search_tool", "pages_search_tool", "docs_search_tool"]: - # For search tools, standardize search metadata - if stored_response: - entity_urls = stored_response.get("entity_urls") or [] - execution_data["entity_urls"] = entity_urls - execution_data["results_count"] = len(entity_urls) - execution_data["search_query"] = tool_args.get("query", "") - # Include execution metadata if available - if "execution_metadata" in stored_response: - execution_data["search_metadata"] = stored_response["execution_metadata"] - execution_data["tool_type"] = "semantic_search" - - elif tool_name == "generic_query_tool": - execution_data["tool_type"] = "generic_llm" - if stored_response and isinstance(stored_response, dict): - execution_data.update({k: v for k, v in stored_response.items() if k not in ["results", "tool_args"]}) - else: - # For other tools, include any available stored response data - execution_data["tool_type"] = "unknown" - if stored_response and isinstance(stored_response, dict): - execution_data.update({k: v for k, v in stored_response.items() if k not in ["results", "tool_args"]}) - - # Sanitize execution_data to ensure JSON serializability - sanitized_execution_data = sanitize_execution_data(execution_data) - - # Extract key facts from tool_result and enrich execution_data - try: - facts: Dict[str, Any] = {} - result_str = str(tool_result) if tool_result is not None else "" - - # Extract explicit entity lines from success formatter - m_url = re.search(r"^Entity URL:\s*(.+)$", result_str, flags=re.MULTILINE) - if m_url: - facts["entity_url"] = m_url.group(1).strip() - m_eid = re.search(r"^Entity ID:\s*(.+)$", result_str, flags=re.MULTILINE) - if m_eid: - facts["entity_id"] = m_eid.group(1).strip() - m_ename = re.search(r"^Entity Name:\s*(.+)$", result_str, flags=re.MULTILINE) - if m_ename: - facts["entity_name"] = m_ename.group(1).strip() - m_etype = re.search(r"^Entity Type:\s*(.+)$", result_str, flags=re.MULTILINE) - if m_etype: - facts["entity_type"] = m_etype.group(1).strip() - m_eident = re.search(r"^Entity Identifier:\s*(.+)$", result_str, flags=re.MULTILINE) - if m_eident: - facts["entity_identifier"] = m_eident.group(1).strip() - - # Extract Result: payload and try to parse dict/list safely - m_res = re.search(r"\n\nResult:\s*(\{[\s\S]*?\}|\[[\s\S]*?\])", result_str) - if m_res: - payload = m_res.group(1) - try: - parsed = ast.literal_eval(payload) - if isinstance(parsed, dict): - for k in ("id", "identifier", "project_id", "workspace_id", "name"): - v = parsed.get(k) - if v: - facts[k] = v - # Common list containers - for list_key in ("projects", "cycles", "results", "items"): - if isinstance(parsed.get(list_key), list): - lst = parsed.get(list_key) or [] - facts[f"{list_key}_count"] = len(lst) - ids = [] - for it in lst[:3]: - if isinstance(it, dict) and it.get("id"): - ids.append(str(it.get("id"))) - if ids: - facts[f"{list_key}_ids_preview"] = ids - elif isinstance(parsed, list): - facts["list_count"] = len(parsed) - ids = [] - for it in parsed[:3]: - if isinstance(it, dict) and it.get("id"): - ids.append(str(it.get("id"))) - if ids: - facts["ids_preview"] = ids - except Exception: - pass - - # Always include a concise args summary as fallback - try: - if isinstance(tool_args, dict): - arg_keys = [ - k for k in ("project_id", "module_id", "cycle_id", "issue_id", "name", "identifier") if k in tool_args - ] - if arg_keys: - facts["args_summary"] = {k: tool_args.get(k) for k in arg_keys} - except Exception: - pass - - if facts: - # Merge into sanitized_execution_data - if isinstance(sanitized_execution_data, dict): - sanitized_execution_data["facts"] = facts - except Exception: - pass - - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL.value, - "tool_name": tool_name, - "content": standardize_flow_step_content(tool_result, FlowStepType.TOOL), - "execution_data": sanitized_execution_data, - "execution_success": ExecutionStatus.SUCCESS, # Mark as SUCCESS after successful execution - }) - current_step += 1 - - # Add to responses for final formatting - agent_name = chatbot_instance._tool_name_to_agent(tool_name) - - # Generic approach: Check if any agent has stored entity URLs - response_with_urls: Union[str, Dict[str, Any]] = tool_result - stored_response = chatbot_instance.agent_responses.get(tool_name) - if stored_response and stored_response.get("entity_urls"): - response_with_urls = stored_response - - responses.append((agent_name, str(tool_args), response_with_urls)) - - # Create tool message - tool_message = ToolMessage(content=str(tool_result), tool_call_id=tool_id or "") - tool_messages.append(tool_message) - - except Exception as e: - log.error(f"ChatID: {chat_id} - Error executing tool {tool_name}: {str(e)}") - # Use a generic, user-friendly reasoning message - reasoning_chunk = "❌ Encountered an unexpected error.\n\n" - if reasoning_container is not None: - reasoning_container["content"] += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - - # Record the failed tool execution with error details - error_execution_data = { - "tool_args": tool_args, - "tool_name": tool_name, - "execution_status": "error", - "error_message": str(e), - "tool_type": "unknown", - } - - # Sanitize error execution_data to ensure JSON serializability - sanitized_error_execution_data = sanitize_execution_data(error_execution_data) - - tool_flow_steps.append({ - "step_order": current_step, - "step_type": FlowStepType.TOOL, - "tool_name": tool_name, - "content": f"Error: {str(e)}", - "execution_data": sanitized_error_execution_data, - "execution_success": ExecutionStatus.FAILED, # Mark as FAILED on error - "execution_error": str(e), - }) - current_step += 1 - - error_message = ToolMessage(content=f"Error executing {tool_name}: {str(e)}", tool_call_id=str(tool_id or "")) - tool_messages.append(error_message) - else: - log.error(f"ChatID: {chat_id} - Tool {tool_name} not found") - # Generic, user-friendly status instead of internal details - reasoning_chunk = "⚠️ A required capability was unavailable.\n\n" - if reasoning_container is not None: - reasoning_container["content"] += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - error_message = ToolMessage(content=f"Tool {tool_name} not found", tool_call_id=str(tool_id or "")) - tool_messages.append(error_message) - - # Add tool messages to conversation - messages.extend(tool_messages) - - # Yield status before getting next LLM response - reasoning_chunk = "🤖 Analyzing results...\n\n" - if reasoning_container is not None: - reasoning_container["content"] += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - - # Get next response from LLM - llm_response = await chatbot_instance._tool_orchestration_llm_call(llm_with_tools, messages) - - # Handle failure case - if llm_response == "TOOL_ORCHESTRATION_FAILURE": - # Finalize with a short fallback message so caller can persist - yield "πspecial reasoning blockπ: 📝 Generating final response...\n\n" - yield "An unexpected error occurred. Please try again later." - return - - ai_message = cast(AIMessage, llm_response) - messages.append(ai_message) - - # Log any follow-up tool calls - if ai_message.tool_calls: - tool_calls_summary = [(tc["name"], tc.get("args", {})) for tc in ai_message.tool_calls] - log.info(f"ChatID: {chat_id} - Tool selection LLM follow-up: {len(ai_message.tool_calls)} tool(s): {tool_calls_summary}") - - # Add all tool flow steps to database - if tool_flow_steps: - try: - async with get_streaming_db_session() as _subdb: - flow_steps_result = await asyncio.wait_for( - _upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=tool_flow_steps, - db=_subdb, - ), - timeout=2.0, - ) - except asyncio.TimeoutError: - log.warning(f"Timed out recording tool flow steps for message {query_id}; continuing") - flow_steps_result = {"message": "error", "error": "timeout"} - except Exception as e: - log.warning(f"Failed to record tool flow steps for message {query_id}: {e}") - flow_steps_result = {"message": "error", "error": str(e)} - if flow_steps_result["message"] != "success": - # Log the error but continue - flow step logging is not critical for user experience - log.warning(f"Failed to record flow steps for message {query_id}: {flow_steps_result.get("error", "Unknown error")}") - # Continue with response generation - user should still get their answer - - # Yield final status - reasoning_chunk = "📝 Generating final response...\n\n" - if reasoning_container is not None: - reasoning_container["content"] += reasoning_chunk - yield f"πspecial reasoning blockπ: {reasoning_chunk}" - - # Format responses for the combination prompt - log.info(f"ChatID: {chat_id} - Multi-agent execution completed. Processing {len(responses)} response(s) from agents") - formatted_responses_result = StandardAgentResponse.format_responses(responses, chat_id, query_flow_store) - - # Check if responses contain errors - if so, return error directly without LLM rewriting - if isinstance(formatted_responses_result, dict) and formatted_responses_result.get("has_errors"): - log.warning(f"ChatID: {chat_id} - Error detected in agent responses, bypassing LLM combination") - # Use friendly generic message for end users - clean_error_msg: str = "An unexpected error occurred. Please try again later." - - # Persist steps before returning early - if tool_flow_steps: - try: - async with get_streaming_db_session() as _subdb: - await _upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=tool_flow_steps, - db=_subdb, - ) - except Exception as _e: - log.warning(f"ChatID: {chat_id} - Failed to persist tool flow steps on retrieval error: {_e}") - - # Finalize so caller can persist a proper assistant message - yield "πspecial reasoning blockπ: 📝 Generating final response...\n\n" - yield clean_error_msg - return - - # Extract formatted string for normal flow - log.info(f"ChatID: {chat_id} - All agents completed successfully, proceeding to LLM combination") - formatted_responses = ( - formatted_responses_result.get("formatted") if isinstance(formatted_responses_result, dict) else formatted_responses_result - ) - formatted_history = format_conversation_history(conversation_history) - - # Stream the combined response - log.debug(f"ChatID: {chat_id} - Starting combined_response_stream with {len(conversation_history)} history messages") - async for chunk in chatbot_instance.combined_response_stream( - parsed_query, - formatted_responses, - formatted_history, - user_meta, - user_id, - workspace_in_context=workspace_in_context, - ): - yield chunk - log.info(f"ChatID: {chat_id} - Multi-agent flow completed successfully") - except Exception as e: - log.error(f"ChatID: {chat_id} - Error in LangChain tool orchestration: {str(e)}") - yield "πspecial reasoning blockπ: 📝 Generating final response...\n\n" - yield "An unexpected error occurred. Please try again later." diff --git a/apps/pi/pi/services/chat/helpers/agent_selector.py b/apps/pi/pi/services/chat/helpers/agent_selector.py deleted file mode 100644 index 87ad61cf8e..0000000000 --- a/apps/pi/pi/services/chat/helpers/agent_selector.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Agent selection and routing logic.""" - -import time -from typing import List -from typing import Tuple -from typing import Type -from typing import Union -from typing import cast - -from langchain_core.prompts import ChatPromptTemplate -from openai import LengthFinishReasonError # type: ignore[attr-defined] -from pydantic.v1 import BaseModel - -from pi import logger -from pi import settings -from pi.app.models.enums import FlowStepType -from pi.app.models.enums import MessageMetaStepType -from pi.services.chat.prompts import router_prompt -from pi.services.chat.utils import standardize_flow_step_content -from pi.services.llm.llms import LLMConfig -from pi.services.llm.llms import create_openai_llm -from pi.services.retrievers.pg_store.message import upsert_message_flow_steps -from pi.services.schemas.chat import AgentQuery -from pi.services.schemas.chat import Agents -from pi.services.schemas.chat import RouteQuery -from pi.services.schemas.chat import RoutingResult - -log = logger.getChild(__name__) - - -async def select_agents( - chatbot_instance, target, parsed_query, enhanced_conversation_history, workspace_in_context, query_id, chat_id, step_order, db -) -> Tuple[List[AgentQuery], int, Union[str, None]]: - """Select appropriate agents based on query and context.""" - selected_agents: list[AgentQuery] = [] - error_message = None - - if workspace_in_context: - # Prepare context-aware prompt for the enhanced router - if enhanced_conversation_history: - custom_prompt = f"Here is the conversation history for context:\n\n{enhanced_conversation_history}\n\nNew user query: {parsed_query}" - else: - custom_prompt = f"Here is the user query: {parsed_query}" - - if target: - # Append note about resolved entity ID to existing prompt - note = f"""Please note that the user has mentioned a specific entity of type {target} using the 'mentions'(@entity_name) syntax in their query. -I resolved the UUID of this entity and enhanced the user query with it above. -Since this is a UUID, please always include the structured DB agent in the selected agents list.""" # noqa: E501 - custom_prompt = f"{custom_prompt}\n\n{note}" - - log.info("************************************************") - log.info(f"ChatID: {chat_id} - Router custom prompt: {custom_prompt}") - log.info("************************************************") - # Create router dynamically with tracking context - route_query_type: Type[BaseModel] = RouteQuery # type: ignore[assignment] - structured_decomposer = chatbot_instance.decomposer_llm.with_structured_output( - route_query_type, - include_raw=True, - method="json_mode", - ) - structured_decomposer.set_tracking_context(query_id, db, MessageMetaStepType.ROUTER, chat_id=str(chat_id)) - - # Create dynamic router chain - json_schema_hint = '{"decomposed_queries": [{"agent": "", "query": ""}]}' - router_prompt_template = ChatPromptTemplate.from_messages([ - ( - "system", - "{base_prompt}\n\nRespond **only** in valid JSON format matching this schema:\n{json_schema_hint}", - ), - ("human", "{custom_prompt}"), - ]).partial(base_prompt=router_prompt, json_schema_hint=json_schema_hint) - dynamic_router = router_prompt_template | structured_decomposer - - # handle openai.LengthFinishReasonError by retrying once with a slightly higher temperature - try: - router_start = time.time() - log.info(f"ChatID: {chat_id} - Starting router LLM call") - routing_result = cast(RoutingResult, await dynamic_router.ainvoke({"custom_prompt": custom_prompt})) - router_elapsed = time.time() - router_start - log.info(f"ChatID: {chat_id} - Router LLM call completed in {router_elapsed:.2f}s") - except LengthFinishReasonError as e: - log.error(f"Error during routing: {e}") - - # Determine a new temperature (current + 0.1, capped at 1.0) - base_temp: float = 0.0 # decomposer_llm default temperature - new_temp: float = min(round(base_temp + 0.1, 2), 1.0) - - log.debug("Retrying routing with increased temperature. Old: %s, New: %s", base_temp, new_temp) - - # Create new decomposer LLM with higher temperature and structured output - # Switch to GPT-4o to mitigate length-finish errors during structured parsing - retry_decomposer_config = LLMConfig(model=settings.llm_model.GPT_4O, temperature=new_temp, streaming=False) - retry_decomposer_llm = create_openai_llm(retry_decomposer_config) - - # Create new structured output and router - # Use the same type alias; mypy: no-redef warning avoided by reusing existing name - route_query_type_retry: Type[BaseModel] = RouteQuery # type: ignore[assignment] - retry_json_llm = retry_decomposer_llm.with_structured_output( - route_query_type_retry, - include_raw=True, - method="json_mode", - ) - - # Set tracking context on the structured LLM instance - retry_json_llm.set_tracking_context(query_id, db, MessageMetaStepType.ROUTER, chat_id=str(chat_id)) - - retry_prompt = ChatPromptTemplate.from_messages([ - ("system", router_prompt), - ("human", "{custom_prompt}"), - ]) - retry_router = retry_prompt | retry_json_llm - - retry_start = time.time() - log.info(f"ChatID: {chat_id} - Starting retry router LLM call") - routing_result = cast(RoutingResult, await retry_router.ainvoke({"custom_prompt": custom_prompt})) - retry_elapsed = time.time() - retry_start - log.info(f"ChatID: {chat_id} - Retry router LLM call completed in {retry_elapsed:.2f}s") - - if routing_result["parsed"] is not None: - parsed_result = routing_result["parsed"] - selected_agents = parsed_result.decomposed_queries - log.info(f"ChatID: {chat_id} - Selected agents before target check: {selected_agents}") - if target: - # look if the actions agent is not in the selected_agents list and plane_structured_database_agent is in the selected_agents list - if Agents.PLANE_ACTION_EXECUTOR_AGENT not in [ - agent.agent.name for agent in selected_agents - ] and Agents.PLANE_STRUCTURED_DATABASE_AGENT in [agent.agent.name for agent in selected_agents]: - selected_agents.append(AgentQuery(agent=Agents.PLANE_STRUCTURED_DATABASE_AGENT, query=parsed_query)) - log.info(f"ChatID: {chat_id} - Added plane_structured_database_agent to selected agents: {selected_agents}") - routing_content = [{"tool": agent.agent.name, "query": agent.query} for agent in selected_agents] - # Log enhanced history storage - history_preview = enhanced_conversation_history[:200] if enhanced_conversation_history else "(empty)" - log.info( - f"ChatID: {chat_id} - Storing enhanced conversation history in routing step (length={len(enhanced_conversation_history or "")}): {history_preview}" # noqa: E501 - ) - flow_step_result = await upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=[ - { - "step_order": step_order, - "step_type": FlowStepType.ROUTING.value, - "tool_name": None, - "content": standardize_flow_step_content(routing_content, FlowStepType.ROUTING), - "execution_data": {"enhanced_conversation_history": enhanced_conversation_history}, - } - ], - db=db, - ) - - if flow_step_result["message"] != "success": - # Log the error but continue - flow step logging is not critical for user experience - log.warning(f"Failed to record agent selection flow step: {flow_step_result.get("error", "Unknown error")}") - # Continue with agent selection - user should still get their answer - - step_order += 1 - else: - log.error("Failed to parse the routing result. Fallback to generic agent.") - selected_agents = [AgentQuery(agent=Agents.GENERIC_AGENT, query=parsed_query)] - else: - agent_query_generic = AgentQuery(agent=Agents.GENERIC_AGENT, query=parsed_query) - selected_agents = [agent_query_generic] - - flow_step_result = await upsert_message_flow_steps( - message_id=query_id, - chat_id=chat_id, - flow_steps=[ - { - "step_order": step_order, - "step_type": FlowStepType.ROUTING.value, - "tool_name": None, - "content": f"Generic agent: {parsed_query}", - "execution_data": {"enhanced_conversation_history": enhanced_conversation_history}, - } - ], - db=db, - ) - - if flow_step_result["message"] != "success": - # Log the error but continue - flow step logging is not critical for user experience - log.warning(f"Failed to record agent selection result flow step: {flow_step_result.get("error", "Unknown error")}") - # Continue with returning selected agents - user should still get their answer - - step_order += 1 - - return selected_agents, step_order, error_message diff --git a/apps/pi/pi/services/chat/helpers/ask_mode_helpers.py b/apps/pi/pi/services/chat/helpers/ask_mode_helpers.py new file mode 100644 index 0000000000..f34d68808a --- /dev/null +++ b/apps/pi/pi/services/chat/helpers/ask_mode_helpers.py @@ -0,0 +1,739 @@ +"""Helper functions for ask mode processing to keep the main flow clean.""" + +import asyncio +import uuid +from typing import Any +from typing import AsyncIterator +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple +from typing import Union + +from langchain_core.messages import BaseMessage +from langchain_core.messages import HumanMessage +from langchain_core.messages import SystemMessage +from pydantic import UUID4 +from sqlmodel.ext.asyncio.session import AsyncSession + +from pi import logger +from pi.app.models.enums import FlowStepType +from pi.app.models.enums import MessageMetaStepType +from pi.services.chat.utils import StandardAgentResponse +from pi.services.chat.utils import get_current_timestamp_context +from pi.services.chat.utils import mask_uuids_in_text +from pi.services.chat.utils import reasoning_dict_maker +from pi.services.chat.utils import resolve_workspace_slug +from pi.services.chat.utils import standardize_flow_step_content +from pi.services.retrievers.pg_store.message import upsert_message_flow_steps + +log = logger.getChild(__name__) + + +# ------------------------------ +# Initialization helpers +# ------------------------------ + + +async def resolve_workspace_id_from_project(project_id: str | None) -> str | None: + """Resolve workspace_id from project_id if workspace_id is not provided.""" + if not project_id: + return None + + try: + from pi.app.api.v1.helpers.plane_sql_queries import resolve_workspace_id_from_project_id + + resolved_workspace_id = await resolve_workspace_id_from_project_id(project_id) + if resolved_workspace_id: + log.info(f"Resolved workspace_id {resolved_workspace_id} from project_id {project_id}") + return str(resolved_workspace_id) + except Exception as e: + log.error(f"Failed to resolve workspace_id from project_id {project_id}: {e}") + + return None + + +def extract_or_create_query_id(user_meta: dict | None) -> uuid.UUID: + """Extract query_id from user_meta token_id or create a new one.""" + query_id = None + try: + if isinstance(user_meta, dict): + token_id = user_meta.get("token_id") + if token_id: + query_id = uuid.UUID(str(token_id)) + except Exception: + pass + + return query_id or uuid.uuid4() + + +async def resolve_workspace_slug_if_needed(workspace_id: str | None, provided_workspace_slug: str | None) -> str | None: + """Resolve workspace slug from workspace_id if not provided.""" + if provided_workspace_slug: + return provided_workspace_slug + + if not workspace_id: + return None + + try: + workspace_uuid = uuid.UUID(workspace_id) + return await resolve_workspace_slug(workspace_uuid, provided_workspace_slug) + except Exception as e: + log.warning(f"Failed to resolve workspace slug: {e}") + return None + + +async def check_and_enrich_clarification_context( + *, chat_id: UUID4, db: AsyncSession, user_meta: dict, parsed_query: str, query_id: UUID4, step_order: int +) -> Tuple[dict, int]: + """ + Check for pending clarifications and enrich user_meta with clarification context. + + Returns: (enriched_user_meta, new_step_order) + """ + try: + from pi.services.retrievers.pg_store.clarifications import get_latest_pending_for_chat as _get_pending_clar + + log.info(f"ChatID: {chat_id} - Checking for pending clarifications...") + clar_row = await _get_pending_clar(db=db, chat_id=uuid.UUID(str(chat_id))) + + if clar_row: + clar_payload = clar_row.payload or {} + log.info( + f"ChatID: {chat_id} - Found pending clarification, enriching user_meta. Kind: {clar_row.kind}, Categories: {clar_row.categories}" + ) + user_meta["clarification_context"] = { + "reason": clar_payload.get("reason"), + "missing_fields": clar_payload.get("missing_fields") or [], + "disambiguation_options": clar_payload.get("disambiguation_options") or [], + "category_hints": clar_payload.get("category_hints") or [], + "answer_text": parsed_query, + "original_query": clar_row.original_query, + "clarifies_message_id": str(clar_row.message_id), + } + log.info(f"ChatID: {chat_id} - Enriched user_meta with clarification_context") + + # Record a clarification_response flow step for traceability + try: + await upsert_message_flow_steps( + message_id=query_id, + chat_id=chat_id, + flow_steps=[ + { + "step_order": step_order + 1, + "step_type": FlowStepType.TOOL.value, + "tool_name": "clarification_response", + "content": standardize_flow_step_content(user_meta.get("clarification_context", {}), FlowStepType.TOOL), + "execution_data": { + "clarifies_message_id": str(clar_row.message_id), + "clarification_resolved": False, + }, + } + ], + db=db, + ) + # Push subsequent steps by +1 + step_order = step_order + 2 + except Exception: + pass + else: + log.info(f"ChatID: {chat_id} - No pending clarification found") + except Exception as _e: + log.warning(f"ChatID: {chat_id} - Clarification table check failed: {_e}") + + return user_meta, step_order + + +async def resolve_pending_clarification(*, chat_id: UUID4, db: AsyncSession, parsed_query: str, query_id: UUID4) -> None: + """Resolve any pending clarification record for this chat.""" + try: + from pi.services.retrievers.pg_store.clarifications import get_latest_pending_for_chat + from pi.services.retrievers.pg_store.clarifications import resolve_clarification + + log.info(f"ChatID: {chat_id} - Processing clarification follow-up") + clar_row = await get_latest_pending_for_chat(db=db, chat_id=uuid.UUID(str(chat_id))) + + if clar_row: + try: + await resolve_clarification( + db, + clarification_id=clar_row.id, + answer_text=parsed_query, + resolved_by_message_id=query_id, + ) + log.info(f"ChatID: {chat_id} - Clarification resolved, continuing with tool execution") + except Exception as e: + log.warning(f"ChatID: {chat_id} - Failed to resolve clarification: {e}") + else: + log.warning(f"ChatID: {chat_id} - No clarification record found") + except Exception as e: + log.error(f"ChatID: {chat_id} - Error processing clarification follow-up: {e}") + + +async def handle_preset_query_flow( + chatbot_instance, + preset_query_steps: List[Dict[str, Any]], + query_id: UUID4, + chat_id: UUID4, + step_order: int, + db: AsyncSession, + user_meta: dict, + workspace_id: str, + project_id: str, + conversation_history: List[Any], + user_id: str, + query_flow_store: dict, + enhanced_query_for_processing: str, + workspace_in_context: bool, + switch_llm: str, + reasoning_container: dict, + source: Optional[str], +) -> AsyncIterator[Union[str, Dict[str, Any]]]: + """ + Handle complete preset query flow: build tools, record routing, execute, and stream response. + + Returns chunks including final response marker. + """ + from pi.services.schemas.chat import ToolQuery + + # Build selected tools from preset config + selected_tools = [] + + for step in preset_query_steps: + reasoning_messages = step.get("reasoning_messages", []) + tools_config = step.get("agents", []) # Still called "agents" in template + + # Show preset reasoning messages + async for chunk in process_preset_reasoning_messages(reasoning_messages, reasoning_container): + yield chunk + + # Convert preset config to ToolQuery format + for tool_config in tools_config: + tool_name = tool_config.get("agent") # Template still uses "agent" key + tool_query_str = tool_config.get("query") + if tool_name and tool_query_str: + selected_tools.append(ToolQuery(tool=tool_name, query=tool_query_str)) + + # Record preset routing as flow step + if selected_tools: + step_order = await record_preset_routing_step(query_id=query_id, chat_id=chat_id, step_order=step_order, selected_tools=selected_tools, db=db) + + # Execute preset tool flow + if selected_tools: + async for chunk in execute_preset_tool_flow( + chatbot_instance, + preset_query_steps, + selected_tools, + user_meta, + query_id, + workspace_id, + project_id, + conversation_history, + user_id, + str(chat_id), + query_flow_store, + enhanced_query_for_processing, + step_order, + workspace_in_context, + db, + switch_llm, + reasoning_container["content"], + source, + ): + yield chunk + + +async def execute_preset_tool_flow( + chatbot_instance, + preset_query_steps: List[Dict[str, Any]], + selected_tools: List[Any], + user_meta: dict, + query_id: UUID4, + workspace_id: str, + project_id: str, + conversation_history: List[Any], + user_id: str, + chat_id: str, + query_flow_store: dict, + enhanced_query_for_processing: str, + step_order: int, + workspace_in_context: bool, + db: AsyncSession, + switch_llm: str, + reasoning: str, + source: Optional[str], +) -> AsyncIterator[Union[str, Dict[str, Any]]]: + """Execute preset tool flow and yield response chunks.""" + try: + # For now, execute the first preset tool (templates currently define one) + preset_step = preset_query_steps[0] if len(preset_query_steps) > 0 else {} + preset_tools_cfg = preset_step.get("agents", []) if isinstance(preset_step, dict) else [] + first_tool_cfg = preset_tools_cfg[0] if preset_tools_cfg else {} + + # Extract preset SQL execution parameters + preset_tables = first_tool_cfg.get("tables") + preset_sql_query = first_tool_cfg.get("sql_query") + preset_placeholders = first_tool_cfg.get("placeholders_in_order") + + # Align sub-step persistence for text2sql with current step + try: + if isinstance(query_flow_store, dict): + query_flow_store["step_order"] = step_order + except Exception: + pass + + # Execute the preset query via the standard tool handler (handles CTEs, flow steps, entity URLs) + result = await chatbot_instance.handle_tool_query( + db=db, + tool=selected_tools[0].tool, + query=selected_tools[0].query, + user_meta=user_meta, + message_id=query_id, + workspace_id=workspace_id, + project_id=project_id, + conv_hist=conversation_history, + user_id=str(user_id), + chat_id=str(chat_id), + query_flow_store=query_flow_store, + is_multi_tool=False, + preset_tables=preset_tables, + preset_sql_query=preset_sql_query, + preset_placeholders=preset_placeholders, + ) + + # Persist a high-level tool execution step so downstream views can extract entity_urls and summaries + try: + tool_name_value = getattr(selected_tools[0].tool, "value", str(selected_tools[0].tool)) + flow_step_payload = { + "step_order": step_order, + "step_type": FlowStepType.TOOL.value, + "tool_name": tool_name_value, + "content": standardize_flow_step_content(result, FlowStepType.TOOL), + "execution_data": result if isinstance(result, dict) else {"results": str(result)}, + "is_planned": False, + "is_executed": True, + "execution_success": "success", + } + await upsert_message_flow_steps(message_id=query_id, chat_id=uuid.UUID(str(chat_id)), flow_steps=[flow_step_payload], db=db) + step_order += 1 + except Exception as e: + log.warning(f"ChatID: {chat_id} - Failed to persist preset tool execution step: {e}") + + # Prepare streaming of the final user-visible text via LLM combiner (applies phrasing rules) + results_text = StandardAgentResponse.extract_results(result) # plain tool output + try: + # Include attachments if any are in shared context + attachment_blocks = getattr(chatbot_instance, "get_current_attachment_blocks", lambda: None)() + except Exception: + attachment_blocks = None + base_stream = chatbot_instance.combined_response_stream( + query=(enhanced_query_for_processing or selected_tools[0].query), + responses=results_text, + conversation_history=conversation_history, + user_meta=user_meta or {}, + user_id=str(user_id), + attachment_blocks=attachment_blocks, + workspace_in_context=workspace_in_context, + ) + + # Stream and persist assistant message + final_response = "" + async for chunk in chatbot_instance._process_response( + base_stream, chat_id, query_id, uuid.uuid4(), switch_llm, db, reasoning=reasoning, source=source + ): + if chunk.startswith("__FINAL_RESPONSE__"): + final_response = chunk[len("__FINAL_RESPONSE__") :] + continue + yield chunk + + if final_response: + query_flow_store["answer"] = final_response + + except Exception as e: + log.error(f"ChatID: {chat_id} - Error executing preset flow: {e}") + raise + + +async def record_preset_routing_step(*, query_id: UUID4, chat_id: UUID4, step_order: int, selected_tools: List[Any], db: AsyncSession) -> int: + """Record preset routing as flow step with timeout protection.""" + routing_content = [{"tool": tool_query.tool.name, "query": tool_query.query} for tool_query in selected_tools] + + try: + flow_step_result = await asyncio.wait_for( + upsert_message_flow_steps( + message_id=query_id, + chat_id=chat_id, + flow_steps=[ + { + "step_order": step_order, + "step_type": FlowStepType.ROUTING.value, + "tool_name": None, + "content": standardize_flow_step_content(routing_content, FlowStepType.ROUTING), + "execution_data": {}, + } + ], + db=db, + ), + timeout=2.0, + ) + if flow_step_result["message"] != "success": + log.warning(f"Failed to record RAG routing flow step: {flow_step_result.get("error", "Unknown error")}") + except asyncio.TimeoutError: + log.warning("Timed out recording RAG routing flow step; continuing") + except Exception as e: + log.warning(f"Failed to record RAG routing flow step: {e}") + + return step_order + 1 + + +async def process_preset_reasoning_messages(reasoning_messages: List[str], reasoning_container: dict) -> AsyncIterator[Union[str, Dict[str, Any]]]: + """Yield preset reasoning messages as special reasoning blocks.""" + for reasoning_msg in reasoning_messages: + masked_reasoning_msg = mask_uuids_in_text(reasoning_msg) + stage = "ask_preset_reasoning" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content=masked_reasoning_msg) + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + + +async def collect_conversation_attachments(*, chat_id: UUID4, db: AsyncSession, current_attachment_ids: List[UUID4]) -> List[UUID4]: + """Collect all attachments from conversation history and merge with current attachments.""" + all_attachment_ids = current_attachment_ids.copy() if current_attachment_ids else [] + + try: + from pi.services.retrievers.pg_store.chat import retrieve_chat_history + + raw_history = await retrieve_chat_history(chat_id=chat_id, db=db, dialogue_object=True) + + if raw_history.get("dialogue"): + attachment_count = 0 + for qa_pair in raw_history["dialogue"]: + if qa_pair.get("attachments"): + for att in qa_pair["attachments"]: + att_id = att.get("id") + if att_id and att_id not in [str(aid) for aid in all_attachment_ids]: + all_attachment_ids.append(uuid.UUID(att_id)) + attachment_count += 1 + + if attachment_count > 0: + log.info(f"ChatID: {chat_id} - Collected {attachment_count} attachments from conversation history") + except Exception as e: + log.warning(f"ChatID: {chat_id} - Failed to collect conversation attachments: {e}") + + return all_attachment_ids + + +async def generate_chat_title_if_needed( + *, + chatbot_instance, + chat_id: UUID4, + query_id: UUID4, + db: AsyncSession, + final_response: str, + parsed_query: str | None, + query: str, +) -> None: + """ + Generate chat title for new chats (first message only). + + Checks if this is the first message and generates a title either: + - Directly from the query (for error responses) + - Using LLM (for successful responses) + """ + try: + from sqlalchemy import select + + from pi.app.models import Message + + # Get the message sequence number to determine if this is a new chat + stmt = select(Message).where(Message.id == query_id) # type: ignore[arg-type] + result = await db.execute(stmt) + message = result.scalar_one_or_none() + + if message: + message_sequence = message.sequence + is_first_message = message_sequence == 1 + else: + log.warning(f"ChatID: {chat_id} - Message {query_id} not found for sequence check") + is_first_message = False + + if is_first_message: + try: + # For error scenarios, use the user's question directly as title + is_error_response = ( + not final_response + or final_response.startswith("An unexpected error occurred") + or final_response == "TOOL_ORCHESTRATION_FAILURE" + or final_response.startswith("Action execution is not available") + or final_response.startswith("Chat ID is required") + or final_response.startswith("Your request timed out") + ) + + title = None + if is_error_response: + # Use the user's original question as title, with some cleanup + title = (parsed_query if parsed_query is not None else query).strip() + # Truncate if too long (keeping it under ~60 characters for readability) + if len(title) > 60: + title = title[:57] + "..." + await chatbot_instance.set_chat_title_directly(chat_id, title, db) + elif final_response: + # For successful responses, generate title using LLM + chat_history = [parsed_query if parsed_query is not None else query, final_response] + # Ensure both elements are strings + chat_history = [str(item) for item in chat_history if item is not None] + if len(chat_history) >= 2: # Make sure we have both query and response + title = await chatbot_instance.generate_title(chat_id, chat_history, db) + # Generated chat title search index upserted via Celery background task + else: + log.warning(f"ChatID: {chat_id} - Not enough valid content to generate title") + title = (parsed_query if parsed_query is not None else query).strip() + # Truncate if too long (keeping it under ~60 characters for readability) + if len(title) > 60: + title = title[:57] + "..." + await chatbot_instance.set_chat_title_directly(chat_id, title, db) + else: + log.warning(f"ChatID: {chat_id} - No final_response available for title generation") + + if title: + log.info(f"ChatID: {chat_id} - Generated title: {title}") + except Exception as e: + log.error(f"Error generating title: {str(e)}") + + except Exception as e: + log.error(f"Error checking message sequence for title generation: {str(e)}") + + +def log_ask_mode_request_details(data: Any, context: Dict[str, Any]) -> None: + """ + Log all important request details for ask mode processing. + + Args: + data: ChatRequest object with original request fields + context: Dict with derived/computed values: + - enhanced_query_for_processing + - attachment_context + - workspace_slug (resolved) + - workspace_id (resolved) + - workspace_in_context (computed) + """ + chat_id = data.chat_id + log.info(f"ChatID: {chat_id} - Input query: {data.query}") + log.info(f"ChatID: {chat_id} - Enhanced query: {context.get("enhanced_query_for_processing")}") + log.info(f"ChatID: {chat_id} - Attachment context: {context.get("attachment_context")}") + log.info(f"ChatID: {chat_id} - User meta: {data.context}") + log.info(f"ChatID: {chat_id} - Workspace in context: {context.get("workspace_in_context", data.workspace_in_context)}") + log.info(f"ChatID: {chat_id} - Workspace slug: {context.get("workspace_slug", data.workspace_slug)}") + log.info(f"ChatID: {chat_id} - Workspace ID: {context.get("workspace_id", str(data.workspace_id) if data.workspace_id else None)}") + log.info(f"ChatID: {chat_id} - Is New Chat: {data.is_new}") + log.info(f"ChatID: {chat_id} - Source: {data.source}") + log.info(f"ChatID: {chat_id} - Is Project Chat: {data.is_project_chat}") + log.info(f"ChatID: {chat_id} - User ID: {data.user_id}") + log.info(f"ChatID: {chat_id} - Project ID: {str(data.project_id) if data.project_id else None}") + log.info(f"ChatID: {chat_id} - LLM: {data.llm}") + + +async def construct_enhanced_prompt_and_context( + enhanced_conversation_history: str | None, + project_id: str | None, + workspace_id: str | None, + user_id: str | None, + user_meta: dict | None, + workspace_in_context: bool = True, +) -> Tuple[str, str]: + """ + Build enhanced custom prompt and context block for LLM orchestration. + + Returns: + Tuple of (custom_prompt, context_block) + """ + from pi.services.chat.helpers.tool_utils import build_clarification_context_block + from pi.services.chat.utils import get_current_timestamp_context + + custom_prompt = "" + + # Inject enhanced conversation history if available to guide orchestration + try: + if enhanced_conversation_history and isinstance(enhanced_conversation_history, str) and enhanced_conversation_history.strip(): + custom_prompt += f"\n\n**CONVERSATION HISTORY & ACTION CONTEXT:**\n{enhanced_conversation_history}\n" + except Exception: + pass + + # Provide PROJECT/USER/TIME context to the tool planner + context_block = "" + try: + # Check if workspace is in context + if not workspace_in_context: + # No workspace context - guide LLM on how to handle queries + context_block += "\n\n**⚠️ NO WORKSPACE CONTEXT:**\n\n**Instructions:**\n- For general questions about Plane features and functionality, use docs_search_tool to search documentation\n- For questions NOT related to Plane, answer directly using your knowledge\n- For questions about specific workspace data (issues, projects, cycles, team members, etc.), politely inform the user:\n 'To access your workspace data, please enable workspace context using the chat input box options.'\n" # noqa: E501 + elif project_id: + # Project scoping (project chat) + context_block += f"\n\n**🔥 PROJECT CONTEXT (CRITICAL):**\nProject ID: {project_id} ⚠️ (FOR YOUR USE ONLY - DO NOT SHOW THIS UUID TO USER)\n\n**IMPORTANT SCOPING RULES:**\n- This is a PROJECT-LEVEL chat - ALL operations are scoped to THIS PROJECT ONLY\n- When the request mentions 'current cycle', 'current module', 'work items', etc. - it means ONLY within THIS PROJECT\n- Use this project_id for ALL tools that accept project_id parameter\n- DO NOT query across all projects - scope everything to THIS specific project\n- User refers to 'this project'/'the project'/'current project' = use this project_id" # noqa: E501 + else: + # Workspace-level context (no specific project) + context_block += f"\n\n**🌐 WORKSPACE CONTEXT (CRITICAL):**\nWorkspace ID: {workspace_id} ⚠️ (FOR YOUR USE ONLY - DO NOT SHOW THIS UUID TO USER)\n\n**IMPORTANT SCOPING RULES:**\n- This is a WORKSPACE-LEVEL chat - queries can span MULTIPLE PROJECTS\n- When the request mentions 'last cycle', 'this cycle', 'work items', etc. WITHOUT specifying a project - it could be in ANY project\n- Use list_member_projects (without limit or with high limit) to get ALL projects in the workspace\n- Then iterate through projects to find relevant cycles/modules/work-items\n- CRITICAL: Do NOT limit to 1 project unless the user specifically names or refers to a specific project" # noqa: E501 + + # User context + if user_id: + context_block += ( + "\n**USER CONTEXT:**\n" + f"User ID: {user_id} ⚠️ (FOR YOUR USE ONLY - DO NOT SHOW THIS UUID TO USER)\n" + "- Use this to resolve pronouns like 'me', 'my', 'assigned to me'.\n" + ) + + # Date/time context + dt_ctx = None + try: + if user_id: + dt_ctx = await get_current_timestamp_context(user_id) + except Exception: + dt_ctx = None + if dt_ctx: + custom_prompt += f"\n{dt_ctx}\n" + except Exception: + # Non-fatal; continue without context block if any error occurs + pass + + # CRITICAL: Inject clarification context if this is a follow-up to ask_for_clarification + if user_meta and isinstance(user_meta, dict) and user_meta.get("clarification_context"): + custom_prompt += build_clarification_context_block(user_meta.get("clarification_context")) + + return custom_prompt, context_block + + +async def resolve_workspace_id_and_slug( + *, + workspace_id: str | None, + project_id: str | None, + chat_id: UUID4, +) -> Tuple[str | None, str | None]: + """ + Resolve workspace_id from project_id if needed, then get workspace_slug. + + Returns: + Tuple of (resolved_workspace_id, workspace_slug) + """ + from pi.app.api.v1.helpers.plane_sql_queries import get_workspace_slug + from pi.app.api.v1.helpers.plane_sql_queries import resolve_workspace_id_from_project_id + + # Resolve workspace_id from project_id if needed (for project-level chats) + if not workspace_id and project_id: + try: + resolved_workspace_id = await resolve_workspace_id_from_project_id(str(project_id)) + if resolved_workspace_id: + workspace_id = str(resolved_workspace_id) + log.info(f"ChatID: {chat_id} - Resolved workspace_id {workspace_id} from project_id {project_id} (Phase 1)") + except Exception as e: + log.error(f"ChatID: {chat_id} - Failed to resolve workspace_id from project_id {project_id}: {e}") + + # Build workspace slug + workspace_slug = None + if workspace_id: + try: + workspace_slug = await get_workspace_slug(workspace_id) + except Exception as e: + log.warning(f"ChatID: {chat_id} - Failed to get workspace_slug for workspace_id {workspace_id}: {e}") + + return workspace_id, workspace_slug + + +# ------------------------------ +# Fast-path helpers (no workspace context) +# ------------------------------ + + +async def create_fast_path_stream( + chatbot_instance, + query_id: UUID4, + db: AsyncSession, + chat_id: UUID4, + user_meta: Dict[str, Any] | None, + user_id: str | None, + enhanced_query_for_processing: str, + enhanced_conversation_history: str | None, + reasoning_container: Dict[str, str] | None = None, +) -> AsyncIterator[Union[str, Dict[str, Any]]]: + """ + Create a fast-path stream for queries without workspace context. + + This bypasses tool orchestration and directly calls the LLM with a simple prompt. + Used when workspace_in_context is False for both Ask and Build modes. + + Args: + chatbot_instance: The chatbot instance with tool_llm attribute + query_id: Message ID for token tracking + db: Database session + chat_id: Chat ID for logging + user_meta: User metadata dict (may contain first_name, email) + user_id: User ID for timestamp context + enhanced_query_for_processing: The user's query (may include attachment context) + enhanced_conversation_history: Previous conversation history + + Yields: + str: Streaming chunks from LLM response + """ + + log.info(f"ChatID: {chat_id} - No workspace context, using fast path (direct LLM call)") + + # Extract user name from user_meta if available + user_first_name = None + user_email = None + if user_meta and isinstance(user_meta, dict): + user_first_name = user_meta.get("first_name") + user_email = user_meta.get("email") + + # Get timestamp context + dt_ctx = None + try: + if user_id: + dt_ctx = await get_current_timestamp_context(user_id) + except Exception: + pass + + # Build context block + context_parts = [] + if user_first_name: + context_parts.append(f"**User:** {user_first_name}") + elif user_email: + context_parts.append(f"**User:** {user_email}") + if dt_ctx: + context_parts.append(dt_ctx.strip()) + context_block = "\n".join(context_parts) if context_parts else "" + + # Build system prompt + system_prompt = f"""You are Plane AI, an AI assistant for Plane - a work management platform. + +**Current Mode:** No workspace context available. + +{context_block} + +**Your role:** +- Answer general knowledge questions not related to Plane +- If user asks about their specific workspace data (issues, projects, cycles, team members, etc.), politely inform them: + "To access your workspace data, please enable workspace context using the chat input box options." + +Be helpful, concise, and professional.""" + + # Build message list + messages: List[BaseMessage] = [SystemMessage(content=system_prompt)] + if enhanced_conversation_history and isinstance(enhanced_conversation_history, str) and enhanced_conversation_history.strip(): + messages.append(SystemMessage(content=f"Conversation History:\n{enhanced_conversation_history}")) + messages.append(HumanMessage(content=enhanced_query_for_processing)) + + # Setup LLM with token tracking + llm = chatbot_instance.tool_llm + llm.set_tracking_context(query_id, db, MessageMetaStepType.TOOL_ORCHESTRATION, chat_id=str(chat_id)) + + # Signal to the caller that we're generating the final response + # This marker is used by chat.py to start collecting chunks for DB persistence + stage = "final_response" + reasoning_chunk_dict = reasoning_dict_maker(stage=stage, tool_name="", tool_query="", content="") + if reasoning_container is not None: + reasoning_container["content"] += reasoning_chunk_dict["header"] + reasoning_chunk_dict["content"] + yield reasoning_chunk_dict + + # Stream LLM response + # Marker used by collector to start capturing final response + async for chunk in llm.astream(messages): + if getattr(chunk, "content", None): + yield str(chunk.content) diff --git a/apps/pi/pi/services/chat/helpers/batch_action_orchestrator.py b/apps/pi/pi/services/chat/helpers/batch_action_orchestrator.py deleted file mode 100644 index f747be3a0c..0000000000 --- a/apps/pi/pi/services/chat/helpers/batch_action_orchestrator.py +++ /dev/null @@ -1,764 +0,0 @@ -"""LLM-based orchestration for batch action execution.""" - -import re -from typing import Any -from typing import Dict -from typing import List -from typing import Optional - -from langchain_core.messages import SystemMessage -from langchain_core.messages import ToolMessage -from sqlmodel.ext.asyncio.session import AsyncSession - -from pi import logger -from pi.services.actions.tools.entity_search import get_entity_search_tools -from pi.services.chat.chat import PlaneChatBot - -from .batch_execution_context import BatchExecutionContext -from .batch_execution_helpers import build_execution_prompt -from .batch_execution_helpers import extract_entity_info -from .batch_execution_helpers import update_flow_step_execution_status - -log = logger.getChild(__name__) - - -class BatchActionOrchestrator: - """Orchestrates batch action execution using LLM.""" - - def __init__(self, chatbot: PlaneChatBot, db: AsyncSession): - self.chatbot = chatbot - self.db = db - - async def execute_planned_actions( - self, - context: BatchExecutionContext, - original_query: str, - planned_actions: List[Dict[str, Any]], - access_token: str, - workspace_slug: str, - ) -> BatchExecutionContext: - """Execute all planned actions using LLM orchestration.""" - - context.set_total_planned(len(planned_actions)) - - try: - # Build execution prompt - execution_prompt = build_execution_prompt(original_query, planned_actions) - - # Create method executor and tools - _, combined_tools = await self._setup_execution_tools(access_token, workspace_slug, context, planned_actions) # noqa: E501 - - if not combined_tools: - context.add_execution_failure("setup", "tool_setup", "No execution tools available", None) - return context - - # Execute using LLM orchestration - await self._orchestrate_with_llm(execution_prompt, combined_tools, planned_actions, context) - - # Mark execution as completed - context.mark_completed() - - except Exception as e: - log.error(f"Batch execution failed: {e}") - context.add_execution_failure("orchestration", "system_error", str(e), None) - - return context - - async def _setup_execution_tools( - self, access_token: str, workspace_slug: str, context: BatchExecutionContext, planned_actions: List[Dict[str, Any]] - ): - """Set up the execution tools for the planned actions.""" - from pi import settings - from pi.services.actions import MethodExecutor - from pi.services.actions import PlaneActionsExecutor - - # Create actions executor - if access_token.startswith("plane_api_"): - actions_executor = PlaneActionsExecutor(api_key=access_token, base_url=settings.plane_api.HOST) - else: - actions_executor = PlaneActionsExecutor(access_token=access_token, base_url=settings.plane_api.HOST) - - method_executor = MethodExecutor(actions_executor) - - # Build context for tools - extract project_id from planned actions - project_id = None - for action in planned_actions: - # Look for project_id in action args - args = action.get("args", {}) - if args.get("project_id"): - project_id = args["project_id"] - break - - if not project_id: - log.warning("🔧 WARNING: No project_id found in planned actions - context auto-fill may fail") - else: - # Check if project_id needs resolution (is not a UUID) - import re - - uuid_regex = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") - if not uuid_regex.match(project_id): - # log.info(f"🔧 Resolving project_id '{project_id}' to UUID before building context") - # Create temporary tools for resolution - temp_executor = PlaneActionsExecutor(access_token=access_token, base_url=settings.plane_api.HOST) - temp_method_executor = MethodExecutor(temp_executor) - temp_context = {"workspace_slug": workspace_slug} - temp_tools = self.chatbot._build_method_tools("entity_search", temp_method_executor, temp_context) - - # Resolve project_id - resolved_project_id = await self._resolve_entity_id("project", project_id, temp_tools, None) - if resolved_project_id: - # log.info(f"🔧 Successfully resolved project_id '{project_id}' to '{resolved_project_id}'") - project_id = resolved_project_id - else: - log.error(f"🔧 Failed to resolve project_id '{project_id}' - will use as-is") - - # workspace_slug is now auto-filled in tool implementations (hidden from LLM) - - tool_context: Dict[str, Any] = { - "workspace_id": str(context.workspace_id), - "workspace_slug": workspace_slug, - "project_id": project_id, # 🔥 FIX: Extract from planned actions - "user_id": str(context.user_id), - "conversation_history": [], - "user_meta": {}, - "is_project_chat": context.is_project_chat, # 🔥 FIX: Pass is_project_chat to tools - } - - log.info( - f"🔧 EXECUTION TOOL CONTEXT: is_project_chat={context.is_project_chat}, workspace_id={context.workspace_id}, chat_id={context.chat_id}" - ) - - # Determine which categories of tools we need based on planned actions - categories = set() - for action in planned_actions: - tool_name = action.get("tool_name", "") - # Ensure tool_name is always a string to avoid NoneType iteration errors - if tool_name is None: - tool_name = "" - elif not isinstance(tool_name, str): - tool_name = str(tool_name) - - if tool_name and "_" in tool_name: - category = tool_name.split("_")[0] - # Map epics to workitems category since epics tools are part of workitems - if category == "epics": - category = "workitems" - categories.add(category) - - log.info(f"🔧 Building execution tools for categories: {categories}") - - # Build tools for all required categories - combined_tools = [] - tool_names_seen = set() - - for category in categories: - try: - tools_for_category = self.chatbot._build_method_tools(category, method_executor, tool_context) - # Only add tools we haven't seen before (deduplicate by name) - tool_names_list = [] - for tool in tools_for_category: - if tool.name not in tool_names_seen: - combined_tools.append(tool) - tool_names_seen.add(tool.name) - tool_names_list.append(tool.name) - log.info(f"🔧 Built tools for category '{category}': {tool_names_list}") - - except Exception as e: - log.warning(f"Failed to build tools for category {category}: {e}") - - # Add entity search tools for placeholder resolution (only if not already included) - try: - entity_search_tools = get_entity_search_tools(method_executor, tool_context) - for tool in entity_search_tools: - if tool.name not in tool_names_seen: - combined_tools.append(tool) - tool_names_seen.add(tool.name) - log.info( - f"🔧 Added {len([t for t in entity_search_tools if t.name not in tool_names_seen])} entity search tools for placeholder resolution" - ) - except Exception as e: - log.warning(f"Failed to build entity search tools: {e}") - - return method_executor, combined_tools - - async def _orchestrate_with_llm( - self, - execution_prompt: str, - combined_tools: List, - planned_actions: List[Dict[str, Any]], - context: BatchExecutionContext, - ): - """Use LLM to orchestrate the execution of planned actions.""" - - # Initialize conversation - messages: List[Any] = [SystemMessage(content=execution_prompt)] - - # Bind tools to LLM - llm_with_tools = self.chatbot.tool_llm.bind_tools(combined_tools) - - # Set tracking context for action execution LLM calls - from pi.app.models.enums import MessageMetaStepType - - llm_with_tools.set_tracking_context(context.message_id, self.db, MessageMetaStepType.ACTION_EXECUTION, chat_id=str(context.chat_id)) - - # Track which actions have been executed - executed_tool_names = set() - used_step_ids: set = set() # Track which step_ids have been matched to prevent double-matching - max_iterations = 15 # Safety limit - iteration_count = 0 - - # Start the orchestration conversation - response = await llm_with_tools.ainvoke(messages) - - # Continue until no more tool calls or we hit limits - while ( - hasattr(response, "tool_calls") and getattr(response, "tool_calls", None) and iteration_count < max_iterations and context.can_continue() - ): - # Add the response with tool calls to messages first - messages.append(response) - iteration_count += 1 - tool_messages = [] - execution_failed = False - - tool_calls = getattr(response, "tool_calls", []) - - # Ensure tool_calls is always a list - if tool_calls is None: - tool_calls = [] - elif not isinstance(tool_calls, (list, tuple)): - log.warning(f"Unexpected tool_calls type: {type(tool_calls)}, converting to list") - tool_calls = [tool_calls] if tool_calls else [] - - # Execute all tool calls in this round - for tool_call in tool_calls: - try: - # Extract tool call information - handle both LangChain objects and dictionaries - if isinstance(tool_call, dict): - # Handle dictionary format (what we're actually getting) - tool_name = tool_call.get("name", "") - tool_args = tool_call.get("args", {}) - tool_id = tool_call.get("id", "") - else: - # Handle LangChain object format - tool_name = getattr(tool_call, "name", "") - tool_args = getattr(tool_call, "args", {}) - tool_id = getattr(tool_call, "id", "") - - if not tool_name: - continue - - if not tool_id: - continue - - # Ensure tool_name is a string and not None - tool_name = str(tool_name) if tool_name is not None else "" - if not tool_name: - continue - - # Find the corresponding planned action with argument matching and consumption tracking - planned_action = self._find_planned_action(tool_name, planned_actions, tool_args, used_step_ids) - if not planned_action: - # Try to find by partial match (LLM might use slightly different names) - planned_action = self._find_planned_action_partial(tool_name, planned_actions) - - # Debug logging for step_id matching - if not planned_action: - log.warning(f"⚠️ Could not match tool_call {tool_name} to any planned action") - - # Ensure we have a valid step_id - if planned_action and planned_action.get("step_id"): - step_id = planned_action["step_id"] - else: - step_id = f"unknown_{context.current_step}" - log.warning(f"No planned action found for tool {tool_name}, using fallback step_id: {step_id}") - - # DEFENSIVE FIX: Before placeholder resolution, check if action_summary has project.id - # and tool_args.project_id is missing or non-UUID. Extract from summary if available. - - uuid_pattern = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE) - if planned_action: - action_summary = planned_action.get("action_summary", {}) - log.info( - f"DEBUG: action_summary type={type(action_summary)}, keys={action_summary.keys() if isinstance(action_summary, dict) else "N/A"}" # noqa: E501 - ) - if isinstance(action_summary, dict): - parameters = action_summary.get("parameters", {}) - log.info( - f"DEBUG: parameters type={type(parameters)}, keys={parameters.keys() if isinstance(parameters, dict) else "N/A"}" # noqa: E501 - ) - if isinstance(parameters, dict): - project_block = parameters.get("project", {}) - log.info(f"DEBUG: project_block type={type(project_block)}, content={project_block}") # noqa: E501 - if isinstance(project_block, dict): - summary_project_id = project_block.get("id") - log.info( - f"DEBUG: summary_project_id={summary_project_id}, is_uuid={uuid_pattern.match(summary_project_id) if isinstance(summary_project_id, str) else False}" # noqa: E501 - ) - if isinstance(summary_project_id, str) and uuid_pattern.match(summary_project_id): - # Check if tool_args needs this project_id - current_project_id = tool_args.get("project_id") if isinstance(tool_args, dict) else None - log.info( - f"DEBUG: current_project_id={current_project_id}, is_uuid={uuid_pattern.match(current_project_id) if isinstance(current_project_id, str) else False}" # noqa: E501 - ) - if not current_project_id or ( - isinstance(current_project_id, str) and not uuid_pattern.match(current_project_id) - ): - # Set project_id from action_summary to avoid resolution - if isinstance(tool_args, dict): - tool_args["project_id"] = summary_project_id - log.info(f"✅ Extracted project_id from action_summary for {tool_name}: {summary_project_id}") - - # Check if tool_args contain placeholders that need resolution - if self._has_placeholders(tool_args): - # log.info(f"Detected placeholders in {tool_name} arguments: {tool_args}") - # Force placeholder resolution before execution - resolved_args = await self._resolve_placeholders(tool_args, combined_tools, context) - if resolved_args: - # log.info(f"Successfully resolved placeholders for {tool_name}: {resolved_args}") - tool_args = resolved_args - else: - # Placeholder resolution failed - error_msg = f"Failed to resolve placeholders in {tool_name} arguments: {tool_args}" - artifact_id = planned_action.get("artifact_id") if planned_action else None - planned_sequence = planned_action.get("step_order") if planned_action else None - artifact_type = planned_action.get("artifact_type") if planned_action else None - context.add_execution_failure( - step_id, tool_name, error_msg, artifact_id, sequence=planned_sequence, artifact_type=artifact_type - ) - execution_failed = True - continue - else: - log.debug(f"No placeholders detected in {tool_name} arguments: {tool_args}") - - # Execute the tool - result = await self._execute_tool(tool_name, tool_args, combined_tools) - - # Check if execution was successful - is_success = self._check_execution_success(result) - - if is_success: - # Extract entity information - entity_info = extract_entity_info(str(result), tool_name) - # Coerce to a single entity dict for downstream consumers (type-safe for mypy) - single_entity_info: Optional[Dict[str, Any]] - if isinstance(entity_info, list): - single_entity_info = entity_info[0] if entity_info else None - elif isinstance(entity_info, dict): - single_entity_info = entity_info - else: - single_entity_info = None - - # Record successful execution - artifact_id = planned_action.get("artifact_id") if planned_action else None - planned_sequence = planned_action.get("step_order") if planned_action else None - artifact_type = planned_action.get("artifact_type") if planned_action else None - context.add_execution_result( - step_id, tool_name, result, single_entity_info, artifact_id, sequence=planned_sequence, artifact_type=artifact_type - ) - executed_tool_names.add(tool_name) - - # Update flow step in database - if planned_action and planned_action.get("step_id"): - # For manual edits, pass the version_id through - version_id = planned_action.get("version_id") - await update_flow_step_execution_status(step_id, str(result), entity_info, True, self.db, version_id) - else: - # Record failed execution - error_msg = f"Tool execution failed: {result}" - artifact_id = planned_action.get("artifact_id") if planned_action else None - planned_sequence = planned_action.get("step_order") if planned_action else None - artifact_type = planned_action.get("artifact_type") if planned_action else None - context.add_execution_failure( - step_id, tool_name, error_msg, artifact_id, sequence=planned_sequence, artifact_type=artifact_type - ) - - # Update flow step in database - if planned_action and planned_action.get("step_id"): - # For manual edits, pass the version_id through (for failed cases too) - version_id = planned_action.get("version_id") - await update_flow_step_execution_status(step_id, str(result), None, False, self.db, version_id) - - # Mark execution as failed but continue to create tool message - execution_failed = True - - # Create tool message for conversation continuity (regardless of success/failure) - tool_message = ToolMessage(content=str(result), tool_call_id=tool_id) - tool_messages.append(tool_message) - - except Exception as e: - log.error(f"Error executing tool {tool_name}: {e}") - # Use fallback step_id for error cases - error_step_id = step_id if "step_id" in locals() and step_id is not None else f"unknown_{context.current_step}" - artifact_id = planned_action.get("artifact_id") if planned_action else None - planned_sequence = planned_action.get("step_order") if planned_action else None - artifact_type = planned_action.get("artifact_type") if planned_action else None - context.add_execution_failure( - error_step_id, tool_name, str(e), artifact_id, sequence=planned_sequence, artifact_type=artifact_type - ) - - # Create error tool message - tool_message = ToolMessage(content=f"Error executing {tool_name}: {str(e)}", tool_call_id=tool_id) - tool_messages.append(tool_message) - execution_failed = True - - # Add tool results to conversation (this must happen before any break) - messages.extend(tool_messages) - - # Verify that all tool calls in this response have corresponding tool messages - response_tool_call_ids = set() - for tc in tool_calls: - if isinstance(tc, dict): - tool_id = tc.get("id", "") - else: - tool_id = getattr(tc, "id", "") - if tool_id: - response_tool_call_ids.add(tool_id) - - added_tool_message_ids = {getattr(tm, "tool_call_id", "") for tm in tool_messages if hasattr(tm, "tool_call_id")} - - missing_in_this_round = response_tool_call_ids - added_tool_message_ids - if missing_in_this_round: - # Create missing tool messages for this round - for missing_id in missing_in_this_round: - placeholder_message = ToolMessage(content="Tool execution was interrupted or failed", tool_call_id=missing_id) - messages.append(placeholder_message) - - # Check if we should continue - if not context.can_continue(): - break - - # Check if all planned actions have been executed (by step_id count, not tool name uniqueness) - total_planned_actions = len(planned_actions) - total_executed_actions = len(used_step_ids) - if total_executed_actions >= total_planned_actions: - # log.info(f"All {total_planned_actions} planned actions have been attempted. Stopping execution loop.") - break - - # If execution failed, stop here but tool messages are already added - if execution_failed: - break - - # Get next response from LLM - try: - response = await llm_with_tools.ainvoke(messages) - except Exception as e: - log.error(f"Error getting LLM response: {e}") - context.add_execution_failure("llm", "orchestration_error", str(e), None) - break - - def _find_planned_action( - self, tool_name: str, planned_actions: List[Dict[str, Any]], tool_args: Optional[Dict[str, Any]] = None, used_step_ids: Optional[set] = None - ) -> Optional[Dict[str, Any]]: - """Find the planned action that matches the tool name and hasn't been used yet.""" - if used_step_ids is None: - used_step_ids = set() - - # First try to match by tool name and key arguments for better accuracy - if tool_args: - for action in planned_actions: - artifact_id = action.get("artifact_id") - - if action.get("tool_name") == tool_name and artifact_id not in used_step_ids: - # Check if key arguments match (for better matching when there are multiple identical tool calls) - planned_args = action.get("args", {}) - - # For workitem operations, match on issue_id - if tool_name.startswith("workitems_") and "issue_id" in tool_args and "issue_id" in planned_args: - if tool_args["issue_id"] == planned_args["issue_id"]: - if artifact_id: # Use artifact_id for tracking instead of step_id - used_step_ids.add(artifact_id) - return action - # For create operations with identical tool names, match on core identifying fields - elif tool_name.endswith("_create") and self._create_args_match(tool_args, planned_args): - if artifact_id: # Use artifact_id for tracking instead of step_id - used_step_ids.add(artifact_id) - return action - # For other operations, can add more specific matching logic as needed - elif self._args_match(tool_args, planned_args): - if artifact_id: # Use artifact_id for tracking instead of step_id - used_step_ids.add(artifact_id) - return action - - # Fallback: match by tool name only, but skip already used actions - for action in planned_actions: - artifact_id = action.get("artifact_id") - if action.get("tool_name") == tool_name and artifact_id not in used_step_ids: - if artifact_id: # Use artifact_id for tracking - used_step_ids.add(artifact_id) - return action - - return None - - def _create_args_match(self, tool_args: Dict[str, Any], planned_args: Dict[str, Any]) -> bool: - """Check if tool arguments match for create operations (where unique identifying fields like name are key).""" - if not tool_args and not planned_args: - return True - - # For create operations, the 'name' field is usually the most distinctive identifier - # If both have name fields, they must match exactly for create operations - if "name" in tool_args and "name" in planned_args: - if str(tool_args["name"]) != str(planned_args["name"]): - return False - # If names match, check other fields for additional validation - other_fields = ["project_id", "priority", "state_id", "type_id"] - for field in other_fields: - if field in tool_args and field in planned_args: - if str(tool_args[field]) != str(planned_args[field]): - return False - return True - - # If no name field, fall back to matching on other distinctive fields - key_fields = ["project_id", "priority", "state_id", "type_id"] - matches = 0 - total_fields = 0 - - for field in key_fields: - if field in tool_args and field in planned_args: - total_fields += 1 - if str(tool_args[field]) == str(planned_args[field]): - matches += 1 - - # Require high match ratio for create operations - return total_fields > 0 and (matches / total_fields) >= 0.8 - - def _args_match(self, tool_args: Dict[str, Any], planned_args: Dict[str, Any]) -> bool: - """Check if tool arguments substantially match planned arguments.""" - if not tool_args and not planned_args: - return True - - # Check key fields that should match - key_fields = ["issue_id", "project_id", "module_id", "cycle_id", "label_id", "state_id", "user_id"] - - for field in key_fields: - if field in tool_args and field in planned_args: - if str(tool_args[field]) != str(planned_args[field]): - return False - - return True - - def _find_planned_action_partial(self, tool_name: str, planned_actions: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: - """Find a planned action that matches the tool name partially.""" - for action in planned_actions: - planned_tool_name = action.get("tool_name", "") - # Ensure both tool names are strings before string operations - if planned_tool_name and isinstance(planned_tool_name, str) and tool_name and isinstance(tool_name, str): - if tool_name in planned_tool_name or planned_tool_name in tool_name: - return action - return None - - async def _execute_tool(self, tool_name: str, tool_args: Dict[str, Any], tools: List) -> Any: - """Execute a specific tool with the given arguments.""" - # Ensure tool_name is a string - if not tool_name or not isinstance(tool_name, str): - log.error(f"Invalid tool name: {tool_name}") - return f"Error: Invalid tool name: {tool_name}" - - # Find the tool - tool_func = next((t for t in tools if getattr(t, "name", "") == tool_name), None) - - if not tool_func: - log.error(f"Tool {tool_name} not found") - return f"Error: Tool {tool_name} not found" - - try: - # Execute the tool - if hasattr(tool_func, "ainvoke"): - result = await tool_func.ainvoke(tool_args) - else: - result = tool_func.invoke(tool_args) - - return result - except Exception as e: - log.error(f"Error executing tool {tool_name}: {e}") - return f"Error executing {tool_name}: {str(e)}" - - def _check_execution_success(self, result: Any) -> bool: - """Check if a tool execution was successful based on the result.""" - result_str = str(result).lower() - - # First check for explicit success indicators (higher priority) - success_indicators = ["✅", "successfully", "created", "updated", "added"] - for indicator in success_indicators: - if indicator in result_str: - return True - - # Then check for failure indicators (but be more specific to avoid false positives) - failure_indicators = ["❌", "failed", "error:", "bad request", "not found", "invalid"] - for indicator in failure_indicators: - if indicator in result_str: - return False - - # Default to success if no clear indicators - return True - - def _has_placeholders(self, tool_args: Dict[str, Any]) -> bool: - """Check if tool arguments contain placeholders or non-UUID *_id values that need resolution.""" - import re - - log.debug(f"_has_placeholders input args: {tool_args}") - # Regex for matching standard UUIDs - fixed pattern to handle UUIDs properly - uuid_regex = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") - entity_keys = {"module", "workitem", "project", "cycle", "label", "state", "user"} - - for key, value in tool_args.items(): - # Special-case: workspace scope sentinel should NOT trigger resolution - if key == "project_id" and isinstance(value, str) and value == "__workspace_scope__": - continue - # 1) Explicit placeholder syntax - if isinstance(value, str) and " Optional[Dict[str, Any]]: - """Resolve placeholders in tool arguments using retrieval tools.""" - import re - - resolved_args = tool_args.copy() - # Use the same UUID regex pattern as _has_placeholders for consistency - uuid_regex = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") - for key, value in list(resolved_args.items()): - # Special-case: workspace scope sentinel - preserve as-is - if key == "project_id" and isinstance(value, str) and value == "__workspace_scope__": - continue - if isinstance(value, str) and "]+)>", value) - if match: - entity_type, entity_name = match.groups() - # Special-case: workspace scope sentinel inside placeholder wrapper - if entity_type == "project" and entity_name.strip() == "__workspace_scope__": - resolved_args[key] = "__workspace_scope__" - continue - resolved_id = await self._resolve_entity_id(entity_type, entity_name, tools, context) - if resolved_id: - resolved_args[key] = resolved_id - else: - log.error(f"Failed to resolve {entity_type} ID for '{entity_name}'") - return None - # 2) Then auto-resolve non-UUID raw names for known *_id fields (skip placeholders) - entity_keys = {"module", "workitem", "project", "cycle", "label", "state", "user"} - for key, val in list(resolved_args.items()): - if key.endswith("_id") and key[:-3] in entity_keys and isinstance(val, str) and "]+)>", item) - if match: - entity_type, entity_name = match.groups() - resolved_id = await self._resolve_entity_id(entity_type, entity_name, tools, context) - if resolved_id: - resolved_list.append(resolved_id) - else: - log.error(f"Failed to resolve {entity_type} ID for '{entity_name}'") - return None - continue - resolved_list.append(item) - resolved_args[key] = resolved_list - return resolved_args - - async def _resolve_entity_id( - self, entity_type: str, entity_name: str, tools: List, context: Optional[BatchExecutionContext] = None - ) -> Optional[str]: - """Resolve a specific entity ID, first checking recent execution results, then using search tools.""" - try: - # First, check if this entity was created in a previous step of the current execution - if context: - for step_id, entity_info in context.entity_results.items(): - if entity_info: - # Check if this step created an entity of the requested type with the requested name - if ( - entity_info.get("entity_type") == entity_type - and entity_info.get("entity_name") == entity_name - and entity_info.get("entity_id") - ): - resolved_id = entity_info.get("entity_id") - return resolved_id - - # If not found in execution results, use search tools - # Map entity types to their search tool names - tool_mapping = { - "module": "search_module_by_name", - "workitem": "search_workitem_by_name", - "project": "search_project_by_name", - "cycle": "search_cycle_by_name", - "label": "search_label_by_name", - "state": "search_state_by_name", - "user": "search_user_by_name", - } - - tool_name = tool_mapping.get(entity_type) - if not tool_name: - log.warning(f"No tool mapping found for entity type: {entity_type}") - return None - - # Find the search tool - tool_func = next((t for t in tools if getattr(t, "name", "") == tool_name), None) - if not tool_func: - log.error(f"Tool {tool_name} not found for resolving {entity_type}") - return None - - # Call the search tool with the entity name - # Special case for user search which expects 'display_name' parameter - if entity_type == "user": - params = {"display_name": entity_name} - else: - params = {"name": entity_name} - - if hasattr(tool_func, "ainvoke"): - result = await tool_func.ainvoke(params) - else: - result = tool_func.invoke(params) - - # Parse the result to extract the ID - if isinstance(result, str): - # The tool returns a formatted string, we need to extract the ID from it - # Format: "✅ Found module 'name'\n\nResult: {'id': 'uuid', 'name': 'name', 'project_id': 'uuid'}" - try: - # Extract the Result section and parse it as a dict - if "Result:" in result: - result_section = result.split("Result:")[1].strip() - # This is a string representation of a dict, we need to parse it - import ast - - result_dict = ast.literal_eval(result_section) - return result_dict.get("id") - except Exception as parse_error: - log.warning(f"Failed to parse result from {tool_name}: {parse_error}") - return None - - return None - - except Exception as e: - log.error(f"Error resolving {entity_type} ID for '{entity_name}': {e}") - return None diff --git a/apps/pi/pi/services/chat/helpers/batch_execution_context.py b/apps/pi/pi/services/chat/helpers/batch_execution_context.py deleted file mode 100644 index fa4e87a5b1..0000000000 --- a/apps/pi/pi/services/chat/helpers/batch_execution_context.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Execution context for managing batch action execution state.""" - -import datetime -from typing import Any -from typing import Dict -from typing import List -from typing import Optional -from uuid import UUID - -from pi import logger - -log = logger.getChild(__name__) - - -class BatchExecutionContext: - """Manages state during batch action execution.""" - - def __init__(self, message_id: UUID, chat_id: UUID, user_id: UUID, workspace_id: UUID, is_project_chat: bool = False): - self.message_id = message_id - self.chat_id = chat_id - self.user_id = user_id - self.workspace_id = workspace_id - self.is_project_chat = is_project_chat - - # Execution state - self.executed_actions: List[Dict[str, Any]] = [] - self.current_step = 0 - self.entity_results: Dict[str, Dict[str, Any]] = {} # Store created entities by step - self.failed_at_step: Optional[int] = None - self.execution_log: List[str] = [] - self.start_time = datetime.datetime.utcnow() - - # Status tracking - self.status = "running" # running, success, failed, partial - self.completed_count = 0 - self.failed_count = 0 - self.total_planned = 0 - - def add_execution_result( - self, - step_id: str, - tool_name: str, - result: Any, - entity_info: Optional[Dict[str, Any]] = None, - artifact_id: Optional[str] = None, - sequence: Optional[int] = None, - artifact_type: Optional[str] = None, - ) -> None: - """Record the result of an executed action.""" - execution_record = { - "step": self.current_step, - "step_id": step_id, - "tool_name": tool_name, - "result": str(result), - "entity_info": entity_info, - "artifact_id": artifact_id, - "sequence": sequence, - "artifact_type": artifact_type, - "executed_at": datetime.datetime.utcnow().isoformat(), - "success": True, - } - - self.executed_actions.append(execution_record) - - if entity_info: - self.entity_results[f"step_{self.current_step}"] = entity_info - self.entity_results[step_id] = entity_info # Also store by step_id for easier lookup - - self.completed_count += 1 - self.current_step += 1 - - def add_execution_failure( - self, - step_id: str, - tool_name: str, - error: str, - artifact_id: Optional[str] = None, - sequence: Optional[int] = None, - artifact_type: Optional[str] = None, - ) -> None: - """Record a failed action execution.""" - execution_record = { - "step": self.current_step, - "step_id": step_id, - "tool_name": tool_name, - "error": error, - "artifact_id": artifact_id, - "sequence": sequence, - "artifact_type": artifact_type, - "executed_at": datetime.datetime.utcnow().isoformat(), - "success": False, - } - - self.executed_actions.append(execution_record) - self.failed_at_step = self.current_step - self.failed_count += 1 - self.current_step += 1 # Increment step counter for failed actions too - - # Don't immediately set status to "failed" - defer until mark_completed() - # This allows all planned actions to be attempted instead of stopping on first failure - # self.status = "failed" # REMOVED - causes early exit - - log.error(f"Action execution failed: {tool_name} (step {self.current_step - 1}) - {error}") - - def log_message(self, message: str) -> None: - """Add a message to the execution log.""" - timestamp = datetime.datetime.utcnow().isoformat() - log_entry = f"[{timestamp}] {message}" - self.execution_log.append(log_entry) - # log.info(message) - - def get_entity_by_step(self, step_identifier: str) -> Optional[Dict[str, Any]]: - """Get entity information by step number or step_id.""" - return self.entity_results.get(step_identifier) - - def get_execution_summary(self) -> Dict[str, Any]: - """Get a summary of the execution state.""" - duration = (datetime.datetime.utcnow() - self.start_time).total_seconds() - - return { - "message_id": str(self.message_id), - "chat_id": str(self.chat_id), - "status": self.status, - "total_planned": self.total_planned, - "completed_count": self.completed_count, - "failed_count": self.failed_count, - "failed_at_step": self.failed_at_step, - "duration_seconds": duration, - "executed_actions": self.executed_actions, - "entity_results": self.entity_results, - "execution_log": self.execution_log[-10:] if self.execution_log else [], # Last 10 log entries - } - - def get_clean_summary(self) -> Dict[str, Any]: - """Get a clean, frontend-friendly summary without debugging information.""" - duration = (datetime.datetime.utcnow() - self.start_time).total_seconds() - - return { - "message_id": str(self.message_id), - "chat_id": str(self.chat_id), - "status": self.status, - "total_planned": self.total_planned, - "completed_count": self.completed_count, - "failed_count": self.failed_count, - "duration_seconds": round(duration, 2), - } - - def get_clean_entity_results(self) -> Dict[str, Any]: - """Get clean entity results without internal debugging information.""" - clean_entities = {} - - for key, entity_info in self.entity_results.items(): - # Skip internal step keys like "step_0", "step_1" - if key.startswith("step_"): - continue - - # Keep only essential entity information - if isinstance(entity_info, dict): - clean_entity = {} - for field, value in entity_info.items(): - # Keep useful fields, skip internal ones - if field in ["entity_url", "entity_name", "entity_type", "entity_id"]: - clean_entity[field] = value - elif field == "tool_name": - clean_entity[field] = value - elif field == "raw_result" and len(str(value)) < 100: - # Only include short raw results - clean_entity[field] = value - - if clean_entity: # Only add if we have useful information - clean_entities[key] = clean_entity - - return clean_entities - - def mark_completed(self) -> None: - """Mark the batch execution as completed with proper status determination.""" - if self.failed_count == 0: - self.status = "success" - elif self.completed_count == 0: - # All actions failed - complete failure - self.status = "failed" - else: - # Mixed results - some succeeded, some failed - self.status = "partial" - - def can_continue(self) -> bool: - """Check if execution can continue (not failed).""" - return self.status != "failed" - - def set_total_planned(self, count: int) -> None: - """Set the total number of planned actions.""" - self.total_planned = count diff --git a/apps/pi/pi/services/chat/helpers/batch_execution_helpers.py b/apps/pi/pi/services/chat/helpers/batch_execution_helpers.py deleted file mode 100644 index 7277d67efb..0000000000 --- a/apps/pi/pi/services/chat/helpers/batch_execution_helpers.py +++ /dev/null @@ -1,414 +0,0 @@ -"""Helper functions for batch action execution.""" - -import datetime -import json -from typing import Any -from typing import Dict -from typing import List -from typing import Optional -from uuid import UUID - -from sqlalchemy import select -from sqlmodel.ext.asyncio.session import AsyncSession - -from pi import logger -from pi.app.models.enums import ExecutionStatus -from pi.app.models.message import Message -from pi.app.models.message import MessageFlowStep -from pi.services.retrievers.pg_store.json_serializer import sanitize_execution_data - -log = logger.getChild(__name__) - - -async def get_planned_actions_for_execution(message_id: UUID, chat_id: UUID, db: AsyncSession) -> List[Dict[str, Any]]: - """Retrieve all planned actions for a message that are ready for execution.""" - try: - # Get flow steps with planned actions - filter for TOOL steps that are planned - stmt = ( - select(MessageFlowStep) - .where(MessageFlowStep.message_id == message_id) # type: ignore[arg-type] - .where(MessageFlowStep.chat_id == chat_id) # type: ignore[arg-type] - .where(MessageFlowStep.is_planned == True) # type: ignore[arg-type] # noqa: E712 - .where(MessageFlowStep.is_executed == False) # type: ignore[arg-type] # noqa: E712 - .order_by(MessageFlowStep.step_order) # type: ignore[arg-type] - ) - result = await db.execute(stmt) - flow_steps = result.scalars().all() - - planned_actions = [] - for step in flow_steps: - execution_data = step.execution_data or {} - - planned_action = { - "step_id": str(step.id), - "step_order": step.step_order, - "tool_name": step.tool_name, - "args": execution_data.get("args", {}), - "action_summary": execution_data.get("action_summary", {}), - "tool_id": execution_data.get("tool_id"), - "artifact_id": execution_data.get("artifact_id"), # NEW: Include artifact ID - "sequence": step.step_order, # NEW: Include planned sequence to propagate - } - planned_actions.append(planned_action) - - return planned_actions - - except Exception as e: - log.error(f"Error retrieving planned actions: {e}") - return [] - - -async def get_original_user_query(message_id: UUID, db: AsyncSession) -> Optional[str]: - """Get the original user query for the message.""" - try: - stmt = select(Message).where(Message.id == message_id) # type: ignore[arg-type] - result = await db.execute(stmt) - message = result.scalar_one_or_none() - - if message: - return message.content - else: - log.warning(f"Message {message_id} not found") - return None - - except Exception as e: - log.error(f"Error retrieving original user query: {e}") - return None - - -def build_execution_prompt(original_query: str, planned_actions: List[Dict[str, Any]]) -> str: - """Build the execution prompt for the LLM.""" - - # Format planned actions for the prompt - actions_text = "" - for i, action in enumerate(planned_actions, 1): - action_summary = action.get("action_summary", {}) - tool_name = action.get("tool_name", "") - args = action.get("args", {}) - - # Create a readable description of the action - action_desc = action_summary.get("action", tool_name) - details = action_summary.get("details", "") - if details: - action_desc += f" - {details}" - - actions_text += f"{i}. {action_desc}\n" - actions_text += f" Tool: {tool_name}\n" - actions_text += f" Arguments: {json.dumps(args, indent=4)}\n\n" - - execution_prompt = f"""You are executing approved actions for this user request: "{original_query}" - -PLANNED ACTIONS (approved by user): -{actions_text} - -EXECUTION MODE: You must now EXECUTE these actions in the correct order using the available tools. - -EXECUTION GUIDELINES: -- Execute each planned action exactly once using the provided tool and arguments -- Use outputs from earlier actions as inputs to later actions when needed -- If an action creates an entity (like an issue, cycle, etc.), use the returned ID in subsequent actions that need it -- Execute actions in logical dependency order (e.g., create issue first, then add to cycle) -- If an action fails, stop execution and report the specific error -- Provide clear status updates after each successful action - -**CRITICAL: PLACEHOLDER HANDLING** -- Some arguments may contain placeholders that need to be resolved -- The system will automatically resolve these placeholders before tool execution -- You should execute the tools with the arguments as provided in the planned actions -- Do not attempt to manually resolve or modify the arguments - -CRITICAL: You must use the EXACT tool names provided above. Do not modify, shorten, or change the tool names in any way. - -AVAILABLE TOOLS: You have access to the following tool categories: {", ".join(set(action.get("tool_name", "").split("_")[0] for action in planned_actions if action.get("tool_name") and isinstance(action.get("tool_name"), str) and "_" in action.get("tool_name", "")))} - -EXAMPLE: If the planned action shows tool_name: "workitems_create", you must call "workitems_create" exactly, not "create_workitem" or "workitem_create". - -IMPORTANT: Start executing the actions now. Use the exact tool names and arguments provided in the planned actions above.""" # noqa: E501 - - return execution_prompt - - -def extract_entity_info(result: str, tool_name: str) -> Optional[Dict[str, Any] | List[Dict[str, Any]]]: - """Extract entity information from tool execution result. - - Returns either a single entity_info dict or a list of entity_info dicts for bulk operations. - """ - try: - result_str = str(result) - entity_info = {} - - # Method 1: Check if the result is already structured JSON containing entity information - try: - if result_str.startswith("{") and result_str.endswith("}"): - result_json = json.loads(result_str) - if isinstance(result_json, dict) and "entity" in result_json: - # Extract entity information from structured response - entity_data = result_json["entity"] - if isinstance(entity_data, dict): - return entity_data - except (json.JSONDecodeError, KeyError): - pass - - # Method 2: Check if the result contains entity URL information (legacy text format) - if "Entity URL:" in result_str: - lines = [ln.strip() for ln in result_str.split("\n")] - - # Fast path: multiple URL lines → build list of entities - url_lines = [ln.replace("Entity URL:", "").strip() for ln in lines if ln.startswith("Entity URL:")] - if len(url_lines) > 1: - name_lines = [ln.replace("Entity Name:", "").strip() for ln in lines if ln.startswith("Entity Name:")] - type_lines = [ln.replace("Entity Type:", "").strip() for ln in lines if ln.startswith("Entity Type:")] - id_lines = [ln.replace("Entity ID:", "").strip() for ln in lines if ln.startswith("Entity ID:")] - ident_lines = [ln.replace("Entity Identifier:", "").strip() for ln in lines if ln.startswith("Entity Identifier:")] - - entities: List[Dict[str, Any]] = [] - max_len = len(url_lines) - for i in range(max_len): - e: Dict[str, Any] = {"entity_url": url_lines[i]} - if i < len(name_lines) and name_lines[i]: - e["entity_name"] = name_lines[i] - if i < len(type_lines) and type_lines[i]: - e["entity_type"] = type_lines[i] - if i < len(id_lines) and id_lines[i]: - e["entity_id"] = id_lines[i] - if i < len(ident_lines) and ident_lines[i]: - e["entity_identifier"] = ident_lines[i] - if e.get("entity_type") == "workitem": - e["issue_identifier"] = ident_lines[i] - entities.append(e) - - # Normalize identifiers from URLs if possible - for e in entities: - try: - url = e.get("entity_url") - if isinstance(url, str) and "/browse/" in url: - after = url.split("/browse/", 1)[1] - identifier = after.split("/", 1)[0] - if "-" in identifier: - parts = identifier.split("-", 1) - if len(parts) == 2 and parts[1].isdigit(): - e["issue_identifier"] = identifier - except Exception: - pass - - return entities - - # Single-entity path - for line in lines: - if line.startswith("Entity URL:"): - entity_info["entity_url"] = line.replace("Entity URL:", "").strip() - elif line.startswith("Entity Name:"): - entity_info["entity_name"] = line.replace("Entity Name:", "").strip() - elif line.startswith("Entity Type:"): - entity_info["entity_type"] = line.replace("Entity Type:", "").strip() - elif line.startswith("Entity ID:"): - entity_info["entity_id"] = line.replace("Entity ID:", "").strip() - elif line.startswith("Entity Identifier:"): - # Generic identifier line (project identifier or work-item unique key) - ident_val = line.replace("Entity Identifier:", "").strip() - entity_info["entity_identifier"] = ident_val - # For work-items, keep backwards-compatible key as well - try: - if entity_info.get("entity_type") == "workitem": - entity_info["issue_identifier"] = ident_val - except Exception: - pass - - if entity_info: - # If we have an entity_url, attempt to parse a work-item unique key from it (e.g., WEB-821) - try: - url = entity_info.get("entity_url") - if isinstance(url, str) and "/browse/" in url: - # Expect format: .../browse/PROJECT-SEQ/ - after = url.split("/browse/", 1)[1] - identifier = after.split("/", 1)[0] - # Basic validation: must contain a hyphen and end with digits - if "-" in identifier: - parts = identifier.split("-", 1) - if len(parts) == 2 and parts[1].isdigit(): - entity_info["issue_identifier"] = identifier - except Exception: - pass - - return entity_info - - # Method 3: For tools that don't provide formatted entity info, try to infer from tool name - if tool_name and isinstance(tool_name, str) and "_create" in tool_name: - # This is a create operation, try to extract basic info - if "created successfully" in result_str.lower() or "✅" in result_str: - entity_info["tool_name"] = tool_name - entity_info["raw_result"] = result_str[:200] # Store first 200 chars - return entity_info - - # Method 4: Extract entity_id from Result JSON section (fallback for when URL construction fails) - if tool_name and isinstance(tool_name, str) and "_create" in tool_name: - try: - # Look for "Result: {json_data}" pattern - if "Result: {" in result_str: - # Extract JSON from Result section - result_start = result_str.find("Result: {") - if result_start != -1: - json_start = result_start + len("Result: ") - # Find the end of the JSON object - json_str = result_str[json_start:] - - # Try to parse the JSON - result_data = json.loads(json_str) - if isinstance(result_data, dict) and result_data.get("id"): - # Extract entity information from the API response - entity_info = { - "entity_id": str(result_data["id"]), - "entity_type": "epic" if "epics_create" in tool_name or "workitems_create" in tool_name else "unknown", - "tool_name": tool_name, - } - - # Add entity name if available - if result_data.get("name"): - entity_info["entity_name"] = result_data["name"] - - return entity_info - except (json.JSONDecodeError, ValueError, KeyError): - # JSON parsing failed, continue to fallback - pass - - return None - - except Exception as e: - log.warning(f"Error extracting entity info from result: {e}") - return None - - -async def update_flow_step_execution_status( - step_id: str, - execution_result: str, - entity_info: Optional[Dict[str, Any] | List[Dict[str, Any]]], - success: bool, - db: AsyncSession, - version_id: Optional[str] = None, -) -> None: - """Update a flow step or artifact version to mark it as executed.""" - try: - # Validate that step_id is a valid UUID - try: - step_uuid = UUID(step_id) - except ValueError as uuid_error: - log.error(f"Invalid UUID format for step_id '{step_id}': {uuid_error}") - return - - # If version_id is provided, this is an edited artifact - update the ActionArtifactVersion - if version_id: - try: - from pi.services.retrievers.pg_store.action_artifact import update_action_artifact_version_execution_status - - # Persist only first entity for version rows for compatibility - _single_entity: Optional[Dict[str, Any]] - if isinstance(entity_info, list): - _single_entity = entity_info[0] if entity_info else None - else: - _single_entity = entity_info - - result = await update_action_artifact_version_execution_status( - db=db, - version_id=UUID(version_id), - is_executed=True, - success=success, - entity_info=_single_entity, - ) - if result: - log.info(f"✅ Successfully updated ActionArtifactVersion {version_id} for step {step_id}: success={success}") - else: - log.error(f"❌ Failed to update ActionArtifactVersion {version_id} for step {step_id}") - - except Exception as version_error: - log.error(f"❌ Error updating ActionArtifactVersion {version_id} for step {step_id}: {version_error}") - return - - # For normal artifacts, update the MessageFlowStep - stmt = select(MessageFlowStep).where(MessageFlowStep.id == step_uuid) # type: ignore[arg-type] - query_result = await db.execute(stmt) - flow_step: Optional[MessageFlowStep] = query_result.scalars().first() - - if not flow_step: - log.error(f"Flow step {step_id} not found") - return - - # Mark as executed - flow_step.is_executed = True - - # Set execution success status and error - if success: - flow_step.execution_success = ExecutionStatus.SUCCESS - flow_step.execution_error = None - else: - flow_step.execution_success = ExecutionStatus.FAILED - flow_step.execution_error = execution_result if "error" in execution_result.lower() else f"Execution failed: {execution_result}" - - # Update execution data - create NEW dict to trigger SQLModel change detection - current_execution_data = flow_step.execution_data or {} - updated_execution_data = { - **current_execution_data, # Preserve existing data - "executed_at": str(datetime.datetime.utcnow()), - "execution_result": execution_result, - "business_success": success, - } - - if entity_info: - if isinstance(entity_info, list): - updated_execution_data["entities"] = entity_info - if entity_info: - updated_execution_data["entity_info"] = entity_info[0] - else: - updated_execution_data["entity_info"] = entity_info - - # Sanitize execution_data to ensure JSON serializability before saving - sanitized_execution_data = sanitize_execution_data(updated_execution_data) - - # Assign NEW dictionary to trigger SQLModel change detection - flow_step.execution_data = sanitized_execution_data - - # Force SQLModel to mark the field as changed - from sqlalchemy.orm import attributes - - attributes.flag_modified(flow_step, "execution_data") - - # Update corresponding artifact if exists (for normal flow) - artifact_id = current_execution_data.get("artifact_id") - if artifact_id: - try: - from pi.services.retrievers.pg_store.action_artifact import update_action_artifact_execution_status - - # Extract single-entity info and entity_id from entity_info if available - _entity_info: Optional[Dict[str, Any]] - if isinstance(entity_info, list): - _entity_info = entity_info[0] if entity_info else None - elif isinstance(entity_info, dict): - _entity_info = entity_info - else: - _entity_info = None - - entity_id = None - if isinstance(_entity_info, dict) and "entity_id" in _entity_info: - entity_id = UUID(str(_entity_info["entity_id"])) - - # Update artifact execution status and persist entity_info for parity (includes entity_url) - await update_action_artifact_execution_status( - db=db, - artifact_id=UUID(artifact_id), - is_executed=True, - success=success, - entity_id=entity_id, - entity_info=_entity_info, - ) - - # log.info(f"Updated artifact {artifact_id} execution status: success={success}") - - except Exception as artifact_error: - log.error(f"Error updating artifact {artifact_id}: {artifact_error}") - # Don't fail the main execution if artifact update fails - - await db.commit() - - except Exception as e: - log.error(f"Error updating flow step execution status: {e}") - await db.rollback() - # Don't re-raise - this is a tracking operation and shouldn't fail the main execution diff --git a/apps/pi/pi/services/chat/helpers/build_mode_helpers.py b/apps/pi/pi/services/chat/helpers/build_mode_helpers.py new file mode 100644 index 0000000000..10f863b280 --- /dev/null +++ b/apps/pi/pi/services/chat/helpers/build_mode_helpers.py @@ -0,0 +1,1360 @@ +"""Helpers to modularize Build-mode orchestration. + +These helpers mirror the approach used for Ask mode and keep the core +execute_tools_for_build_mode function lean and readable. +""" + +from __future__ import annotations + +import ast +import datetime +import json +import re +import uuid +from copy import deepcopy +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Sequence +from typing import Tuple +from typing import Union + +from langchain_core.messages import ToolMessage +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.utils.json import parse_json_markdown +from sqlmodel.ext.asyncio.session import AsyncSession + +from pi import logger +from pi import settings +from pi.app.models.enums import ExecutionStatus +from pi.app.models.enums import FlowStepType +from pi.app.models.enums import MessageMetaStepType +from pi.core.db.plane_pi.lifecycle import get_streaming_db_session +from pi.services.actions import MethodExecutor +from pi.services.actions import PlaneActionsExecutor +from pi.services.actions.artifacts.utils import serialize_for_json +from pi.services.actions.registry import get_available_categories +from pi.services.actions.registry import get_category_methods +from pi.services.actions.tools.entity_search import get_entity_search_tools +from pi.services.chat.helpers.action_property_mapper import map_tool_properties +from pi.services.chat.helpers.tool_utils import TOOL_NAME_TO_CATEGORY_MAP +from pi.services.chat.helpers.tool_utils import category_display_name +from pi.services.chat.helpers.tool_utils import clean_tool_args_for_storage +from pi.services.chat.helpers.tool_utils import handle_missing_required_fields +from pi.services.chat.prompts import action_category_router_prompt +from pi.services.chat.utils import standardize_flow_step_content +from pi.services.retrievers.pg_store.message import upsert_message_flow_steps as _upsert_message_flow_steps +from pi.services.schemas.chat import ActionCategoryRouting +from pi.services.schemas.chat import ActionCategorySelection + +log = logger.getChild(__name__) + + +async def check_oauth_for_build_mode( + *, + chatbot_instance, + user_id: str, + workspace_id: Optional[str], + workspace_slug: Optional[str], + project_id: Optional[str], + chat_id: str, + query_id, + step_order: int, + combined_tool_query: str, + enhanced_conversation_history: Optional[str], + is_project_chat: Optional[bool], + pi_sidebar_open: Optional[bool], + sidebar_open_url: Optional[str], + db, +) -> Optional[str]: + """Check OAuth token early for build mode. Returns auth message if OAuth required, None otherwise. + + This is called BEFORE building any tools, since build mode always requires OAuth for actions. + """ + if not workspace_id: + log.warning(f"ChatID: {chat_id} - No workspace_id provided for build mode OAuth check") + return None + + try: + # Resolve workspace_slug if not provided + if not workspace_slug: + from pi.app.api.v1.helpers.plane_sql_queries import get_workspace_slug + + workspace_slug = await get_workspace_slug(workspace_id) + + if not workspace_slug: + log.warning(f"ChatID: {chat_id} - Could not resolve workspace slug for workspace {workspace_id}") + return None + + # Check for OAuth token + access_token = await chatbot_instance._get_oauth_token_for_user(db, user_id, workspace_id) + + if not access_token: + # No valid OAuth token - create auth required tool and get message + log.info(f"ChatID: {chat_id} - OAuth required for user {user_id} in workspace {workspace_id} (build mode)") + + auth_tools = chatbot_instance._create_auth_required_tools( + workspace_id=workspace_id, + user_id=user_id, + chat_id=chat_id, + message_token=str(query_id), + is_project_chat=is_project_chat, + project_id=project_id, + pi_sidebar_open=pi_sidebar_open, + sidebar_open_url=sidebar_open_url, + workspace_slug=workspace_slug, + ) + + if auth_tools and len(auth_tools) > 0: + auth_tool = auth_tools[0] + auth_message = await auth_tool.ainvoke(combined_tool_query) + + # Persist OAuth flow step for queue mechanism + async with get_streaming_db_session() as _subdb: + flow_step_result = await _upsert_message_flow_steps( + message_id=query_id, + chat_id=uuid.UUID(str(chat_id)), + flow_steps=[ + { + "step_order": step_order, + "step_type": FlowStepType.TOOL.value, + "tool_name": "QUEUE", + "content": "OAuth authorization required", + "execution_data": { + "query": combined_tool_query, + "chat_id": str(chat_id), + "workspace_id": str(workspace_id) if workspace_id else "", + "workspace_slug": workspace_slug or "", + "project_id": str(project_id) if project_id else "", + "user_id": str(user_id) if user_id else "", + "enhanced_conversation_history": enhanced_conversation_history, + }, + "oauth_required": True, + "is_planned": False, + "is_executed": False, + "execution_success": ExecutionStatus.PENDING, + } + ], + db=_subdb, + ) + if flow_step_result.get("message") != "success": + log.warning("Failed to create OAuth flow step") + + return auth_message + + # OAuth token exists - proceed with build mode + return None + + except Exception as e: + log.error(f"ChatID: {chat_id} - Error checking OAuth for build mode: {e}") + return None + + +async def build_advisory_tool_step( + *, + combined_tool_query: str, + current_step: int, +) -> Tuple[str, Dict[str, Any], int]: + """Call category advisory function directly and return advisory text + flow-step. + + Returns: (advisory_text, flow_step_dict, next_step) + """ + + categories = get_available_categories() + + lines: list[str] = [] + lines.append("Available Plane action categories and methods:\n") + for cat, description in categories.items(): + try: + cat_methods = get_category_methods(cat) + method_names = ", ".join(cat_methods.keys()) if cat_methods else "-" + except Exception as exc: + log.warning(f"Failed to get methods for category '{cat}': {exc}") + method_names = "(error retrieving methods)" + lines.append(f"- {cat}: {description}") + lines.append(f" Methods: {method_names}") + + advisory_text = "\n".join(lines) + + flow_step = { + "step_order": current_step, + "step_type": FlowStepType.TOOL, + "tool_name": "get_available_plane_actions", + "content": standardize_flow_step_content(advisory_text, FlowStepType.TOOL), + "execution_data": {"args": {"user_intent": combined_tool_query}}, + } + return str(advisory_text), flow_step, current_step + 1 + + +async def run_category_router_and_persist( + chatbot_instance, + advisory_text: str, + combined_tool_query: str, + enhanced_conversation_history: Optional[str], + query_id, + chat_id, + current_step: int, + db, +) -> Tuple[List[ActionCategorySelection | Dict[str, Optional[str]]], int]: + """Run the LLM-based category router and persist a routing step. + + Returns: (selections_list, next_step) + """ + + custom_prompt = f"User intent: {combined_tool_query}\n\nAdvisory: {advisory_text}" + if enhanced_conversation_history and isinstance(enhanced_conversation_history, str) and enhanced_conversation_history.strip(): + custom_prompt = f"CONVERSATION HISTORY & ACTION CONTEXT:\n{enhanced_conversation_history}\n\n" + custom_prompt + + router_prompt_template = ChatPromptTemplate.from_messages([ + ("system", action_category_router_prompt), + ("human", "{custom_prompt}"), + ]) + + # For Claude models: Don't use with_structured_output as it doesn't handle markdown-wrapped JSON properly + # Instead, get raw response and manually parse the JSON + action_router = chatbot_instance.decomposer_llm + action_router.set_tracking_context(query_id, db, MessageMetaStepType.ACTION_CATEGORY_ROUTING, chat_id=str(chat_id)) + dynamic_action_router = router_prompt_template | action_router + + # Get raw response from LLM + raw_response = await dynamic_action_router.ainvoke(input=custom_prompt) + + # Extract content and parse JSON (handles markdown-wrapped JSON from Claude) + content = raw_response.content if hasattr(raw_response, "content") else str(raw_response) + + try: + # parse_json_markdown handles both raw JSON and markdown-wrapped JSON + parsed_data = parse_json_markdown(content) + # Validate against Pydantic model + parsed_obj = ActionCategoryRouting.model_validate(parsed_data) + except Exception as e: + log.error(f"ChatID: {chat_id} - Failed to parse category routing response: {e}") + log.error(f"ChatID: {chat_id} - Raw content: {content}") + # Fallback to empty selections + parsed_obj = ActionCategoryRouting(selections=[]) + selections_list: List[ActionCategorySelection | Dict[str, Optional[str]]] = [] + if parsed_obj and getattr(parsed_obj, "selections", None): + selections_list = list(parsed_obj.selections) + + # Persist routing decision immediately (keeps order correct with subsequent steps) + routing_content: List[Dict[str, Optional[str]]] = [] + for sel in selections_list: + if isinstance(sel, ActionCategorySelection): + routing_content.append({"category": sel.category, "rationale": sel.rationale}) + elif isinstance(sel, dict): + routing_content.append({"category": sel.get("category"), "rationale": sel.get("rationale")}) + + async with get_streaming_db_session() as _subdb: + flow_step_result = await _upsert_message_flow_steps( + message_id=query_id, + chat_id=uuid.UUID(str(chat_id)), + flow_steps=[ + { + "step_order": current_step, + "step_type": FlowStepType.ROUTING.value, + "tool_name": "action_category_router", + "content": standardize_flow_step_content(routing_content, FlowStepType.ROUTING), + "execution_data": {"skip_category_selection": False, "enhanced_conversation_history": enhanced_conversation_history}, + } + ], + db=_subdb, + ) + if flow_step_result.get("message") != "success": + log.warning("Failed to record category routing in database") + return selections_list, current_step + 1 + + +def build_planning_tools( + *, + chatbot_instance, + selections_list: Sequence[ActionCategorySelection | Dict[str, Optional[str]]], + method_executor, + context: Dict[str, Any], + fresh_retrieval_tools: List[Any], +) -> Tuple[List[Any], List[Any], List[str]]: + """Build method tools for categories, merge with retrieval tools, and return combined. + + Returns: (combined_tools, all_method_tools, built_categories) + """ + project_scoped_cats = [ + "cycles", + "cycle", + "modules", + "module", + "worklogs", + "worklog", + "epics", + "epic", + "intake", + "intakes", + "properties", + "property", + "types", + "type", + "pages", + "page", + ] + all_method_tools: List[Any] = [] + built_categories: List[str] = [] + for sel in selections_list: + cat: Optional[str] + if isinstance(sel, ActionCategorySelection): + cat = sel.category + else: + cat = sel.get("category") # type: ignore[assignment] + if not cat or cat in built_categories: + continue + built_categories.append(cat) + # Skip retrieval_tools - it's a meta-category, not an API category with methods + if cat == "retrieval_tools": + continue + try: + tools_for_cat = chatbot_instance._build_planning_method_tools(cat, method_executor, context) + all_method_tools.extend(tools_for_cat) + + if cat in project_scoped_cats: + tools_for_project = chatbot_instance._build_planning_method_tools("projects", method_executor, context) + # remove all tools except projects_retrieve and projects_update + 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) + + except Exception as e: + log.warning(f"Failed to build tools for category {cat}: {e}") + + # Add all entity search tools + try: + entity_search_tools = get_entity_search_tools(method_executor, context) + all_method_tools.extend(entity_search_tools) + except Exception as e: + log.warning(f"Failed to add entity search tools: {e}") + + method_tool_names = {getattr(t, "name", "") for t in all_method_tools} + combined_tools = all_method_tools + [t for t in fresh_retrieval_tools if getattr(t, "name", "") not in method_tool_names] + return combined_tools, all_method_tools, built_categories + + +def build_tool_orchestration_context_step( + current_step: int, + enhanced_conversation_history: Optional[str], + combined_tool_query: str, + built_categories: List[str], + all_method_tools: List[Any], + combined_tools: List[Any], +) -> Tuple[Optional[Dict[str, Any]], int]: + """Optionally build a flow step capturing orchestration context for auditability.""" + if not (enhanced_conversation_history and isinstance(enhanced_conversation_history, str) and enhanced_conversation_history.strip()): + return None, current_step + + step = { + "step_order": current_step, + "step_type": FlowStepType.TOOL.value, + "tool_name": "tool_orchestration_context", + "content": "Context used for method planning", + "execution_data": { + "enhanced_conversation_history": enhanced_conversation_history, + "tool_query": combined_tool_query, + "selected_categories": list(built_categories), + "available_tools_count": len(combined_tools), + "method_tool_names": [getattr(t, "name", "") for t in all_method_tools], + "bound_tool_names": [getattr(t, "name", "") for t in combined_tools], + }, + "is_planned": False, + "is_executed": False, + "execution_success": ExecutionStatus.PENDING, + } + return step, current_step + 1 + + +def build_planner_tool_selection_step(response, *, current_step: int) -> Tuple[Optional[Dict[str, Any]], int]: + """Build the planner_tool_selection flow step from the LLM response.""" + try: + if hasattr(response, "tool_calls") and getattr(response, "tool_calls", None): + selected_calls: list[dict] = [] + for _tc in getattr(response, "tool_calls", []) or []: + if isinstance(_tc, dict): + _name = _tc.get("name", "") + _args = _tc.get("args", {}) + _id = _tc.get("id", "") + else: + _name = getattr(_tc, "name", "") + _args = getattr(_tc, "args", {}) + _id = getattr(_tc, "id", "") + selected_calls.append({"name": _name, "args": _args, "id": _id}) + + reasoning_text = str(getattr(response, "content", "") or "").strip() + + step = { + "step_order": current_step, + "step_type": FlowStepType.TOOL, + "tool_name": "planner_tool_selection", + "content": standardize_flow_step_content({"selected_tool_calls": selected_calls, "reasoning": reasoning_text}, FlowStepType.TOOL), + "execution_data": { + "selected_tool_calls": selected_calls, + "reasoning": reasoning_text, + }, + "is_planned": False, + "is_executed": False, + "execution_success": ExecutionStatus.PENDING, + } + return step, current_step + 1 + except Exception: + pass + return None, current_step + + +# ------------------------------ +# Planning loop helpers +# ------------------------------ + + +async def execute_and_persist_clarification( + *, + tool_args: Dict[str, Any], + tool_id: str, + combined_tools: List[Any], + combined_tool_query: str, + current_step: int, + built_categories: List[str], + all_method_tools: List[Any], + chat_id, + query_id, + db, +) -> Tuple[Dict[str, Any], Any, int, str]: + """Execute ask_for_clarification, persist flow step and create clarification record. + + Returns: (flow_step_dict, tool_message, next_step, stream_chunk) + """ + tool_func = next((t for t in combined_tools if getattr(t, "name", "") == "ask_for_clarification"), None) + result = None + try: + if tool_func is not None: + if hasattr(tool_func, "ainvoke"): + result = await tool_func.ainvoke(tool_args) + else: + result = tool_func.invoke(tool_args) + else: + result = "{}" + except Exception: + result = "{}" + + try: + clarification_payload = json.loads(str(result)) if result else {} + except Exception: + clarification_payload = {"raw": str(result)} + + tool_message = ToolMessage(content=str(result), tool_call_id=tool_id) + + flow_step = { + "step_order": current_step, + "step_type": FlowStepType.TOOL, + "tool_name": "ask_for_clarification", + "content": standardize_flow_step_content(clarification_payload, FlowStepType.TOOL), + "execution_data": { + "args": tool_args, + "clarification_payload": clarification_payload, + "selected_categories": list(built_categories or []), + "method_tool_names": [getattr(t, "name", "") for t in (all_method_tools or [])], + "original_query": combined_tool_query, + }, + "is_planned": False, + "is_executed": False, + "execution_success": ExecutionStatus.PENDING, + } + + # Create clarification record + try: + from pi.services.retrievers.pg_store.clarifications import create_clarification + + clar_id = await create_clarification( + db, + chat_id=uuid.UUID(str(chat_id)), + message_id=uuid.UUID(str(query_id)), + kind="action" if all_method_tools else "retrieval", + original_query=combined_tool_query, + payload=clarification_payload or {}, + categories=[str(c) for c in (built_categories or [])], + method_tool_names=[getattr(t, "name", "") for t in (all_method_tools or [])], + bound_tool_names=[getattr(t, "name", "") for t in (combined_tools or [])], + ) + log.info(f"ChatID: {chat_id} - Created clarification record: id={clar_id}") + except Exception as e: + log.warning(f"ChatID: {chat_id} - Failed to create clarification record: {e}") + + # Build stream chunk + try: + stream_chunk = f"πspecial clarification blockπ: {json.dumps(clarification_payload)}\n" + except Exception: + stream_chunk = f"πspecial clarification blockπ: {str(result)}\n" + return flow_step, tool_message, current_step + 1, stream_chunk + + +async def plan_action_and_prepare_outputs( + tool_name: str, + tool_args: Dict[str, Any], + tool_id: str, + current_step: int, + chat_id: str, + query_id: str, + conversation_history: List[Any], + combined_tool_query: str, + db: AsyncSession, + project_id: Optional[str] = None, + workspace_slug: Optional[str] = None, +) -> Tuple[Dict[str, Any], Dict[str, Any], ToolMessage, int]: + """Generate action summary, artifact, planned step, ack tool message, and stream chunk. + Returns: (artifact_content, action_summary, tool_message_ack, next_step) + """ + + _tool_args = deepcopy(tool_args) + + # Inject context if missing + if project_id and "project_id" not in tool_args: + tool_args["project_id"] = project_id + _tool_args["project_id"] = project_id + + if workspace_slug and "workspace_slug" not in tool_args: + tool_args["workspace_slug"] = workspace_slug + _tool_args["workspace_slug"] = workspace_slug + + cleaned_args = clean_tool_args_for_storage(tool_args) + + action_summary: Dict[str, Any] = {} + + # Extract root keys from tool_name itself + category = TOOL_NAME_TO_CATEGORY_MAP.get(tool_name, {"action_type": "unknown", "entity_type": "unknown"}) + action_type = category["action_type"] + artifact_type = category["entity_type"] + + action_summary = { + "action": action_type, + "artifact_type": artifact_type, + "tool_name": tool_name, + } + + # Build parameters from tool_args + parameters: Dict[str, Any] = {} + + if "name" in _tool_args.keys(): + parameters["name"] = _tool_args.pop("name") + if "description" in _tool_args.keys(): + parameters["description"] = _tool_args.pop("description") + if "description_html" in _tool_args.keys(): + parameters["description"] = _tool_args.pop("description_html") + if "project_id" in _tool_args.keys(): + parameters["project"] = {"id": _tool_args.pop("project_id")} + + # Add other parameters into properties sub-dict + properties = map_tool_properties(artifact_type, _tool_args) + if properties: + parameters["properties"] = properties + + action_summary["parameters"] = parameters + + # Assign placeholder artifact id; will be updated later by artifact creation + action_summary["artifact_id"] = str(uuid.uuid4()) + action_summary["sequence"] = current_step + action_summary["message_id"] = query_id + + # Build planning ack + try: + # Build a planning ack that includes a human-readable placeholder reference + # for entities created in this plan, and intentionally omits internal artifact IDs + # to avoid the LLM mistaking them for real API IDs. + + placeholder_ref = None + if action_type == "create": + # Prefer 'name', fallback to 'title' for display name + _display_name = None + try: + if isinstance(tool_args, dict): + for _k in ("name", "title"): + _v = tool_args.get(_k) + if isinstance(_v, str) and _v.strip(): + _display_name = _v.strip() + break + except Exception: + _display_name = None + + if isinstance(artifact_type, str) and artifact_type and _display_name: + placeholder_ref = f"" + + ack_payload = { + "status": "planned", + "tool": tool_name, + "args": cleaned_args, + "sequence": current_step, + } + if placeholder_ref: + ack_payload["placeholder_ref"] = placeholder_ref + + ack_content = json.dumps(ack_payload, default=str) + except Exception: + ack_content = f"PLANNED:{tool_name}" + + tool_message_ack = ToolMessage(content=ack_content, tool_call_id=tool_id) + + # Create artifact data to be stored in the database for execution after user confirms the action + # Prepare context + planning_context = { + "original_query": combined_tool_query, + "planning_timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "conversation_context": { + "has_conversation_history": len(conversation_history) > 0, + "previous_message_count": len(conversation_history), + }, + } + artifact_data = { + "planning_data": action_summary, + "tool_args": cleaned_args, + "tool_args_raw": tool_args, + "planning_context": planning_context, + "tool_id": tool_id, + } + if placeholder_ref: + artifact_data["placeholder_ref"] = placeholder_ref + + # Create artifact and get the database-assigned ID + artifact_result = None + artifact_id = None + try: + from pi.services.retrievers.pg_store.action_artifact import create_action_artifact + + artifact_result = await create_action_artifact( + db=db, + chat_id=uuid.UUID(str(chat_id)), + entity=artifact_type, + action=action_type, + data={ + **serialize_for_json(artifact_data), + }, + message_id=uuid.UUID(str(query_id)), + sequence=current_step, + is_executed=False, + ) + + # Extract the artifact ORM object and update action_summary with actual DB ID + if artifact_result and artifact_result.get("message") == "success": + artifact_obj = artifact_result.get("artifact") + if artifact_obj and hasattr(artifact_obj, "id"): + # Update action_summary with the actual database-assigned artifact ID + artifact_id = str(artifact_obj.id) + action_summary["artifact_id"] = artifact_id + else: + log.info("Artifact created but no ID found in result") + else: + error_msg = artifact_result.get("error", "Unknown error") if artifact_result else "No result returned" + log.info(f"Failed to create artifact: {error_msg}") + + except Exception as e: + log.info(f"Exception creating artifact: {str(e)}", exc_info=True) + + # Create the flow step for this planned action + + planned_step = { + "step_type": FlowStepType.TOOL, + "step_order": current_step, + "tool_name": tool_name, + "execution_data": { + "artifact_id": artifact_id, + "tool_name": tool_name, + "entity_type": artifact_type, + "action_type": action_type, + "args": cleaned_args, + }, + "is_planned": True, # Required for get_planned_actions_for_execution query + "is_executed": False, + "execution_success": ExecutionStatus.PENDING, + } + + return planned_step, action_summary, tool_message_ack, current_step + 1 + + +async def enrich_tool_query_for_display( + tool_name: str, + tool_args: Dict[str, Any], + tool_query: str, +) -> str: + """Enrich the display text for certain tools with human-friendly context (e.g., project name). + + Args: + tool_name: Name of the tool being executed + tool_args: Arguments passed to the tool + tool_query: Base tool query string from format_tool_query_for_display + + Returns: + Enhanced tool query string with human-friendly context + """ + try: + if tool_name == "search_current_cycle": + proj_id = tool_args.get("project_id") + if isinstance(proj_id, str) and proj_id: + try: + from pi.app.api.v1.helpers.plane_sql_queries import get_project_details_for_artifact as _get_proj + + proj = await _get_proj(str(proj_id)) + if isinstance(proj, dict): + pname = proj.get("name") + pident = proj.get("identifier") + if pname and pident: + tool_query = f"current cycle in {pname} ({pident})" + elif pname or pident: + tool_query = f"current cycle in project {pname or pident}" + else: + tool_query = f"current cycle in project {proj_id}" + except Exception: + # Keep original tool_query on any lookup failure + pass + elif tool_name == "list_member_projects": + # Clarify scope as workspace projects if workspace context is present + wid = tool_args.get("workspace_id") + if isinstance(wid, str) and wid: + tool_query = "your projects in this workspace" + except Exception: + pass + return tool_query + + +async def execute_retrieval_tool_and_build_step( + *, + tool_name: str, + tool_id: str, + tool_args: Dict[str, Any], + combined_tool_query: str, + combined_tools: List[Any], + query_flow_store: Dict[str, Any], + current_step: int, + tool_query: str, +) -> Tuple[Optional[Dict[str, Any]], Optional[Any], int, str, ExecutionStatus, Optional[str]]: + """Execute a retrieval tool, build flow step and tool message. + + Args: + tool_name: Name of the tool to execute + tool_id: ID of the tool call + tool_args: Arguments for the tool + combined_tool_query: Original user query + combined_tools: List of available tools + query_flow_store: Store for query flow data + current_step: Current step number + tool_query: Pre-formatted and enriched tool query string for display + + Returns: (flow_step_dict, tool_message, next_step, tool_query_display, execution_status, execution_error) + """ + getattr(getattr(__import__("builtins"), "str"), "__call__")(tool_name) # harmless label; actual friendly name handled upstream + # Ensure mypy understands this can be None when tool_call_id is missing + tool_message: Optional[Any] = None + # Seed base for text2sql + try: + if tool_name == "structured_db_tool" and isinstance(query_flow_store, dict): + query_flow_store["step_order"] = current_step + except Exception: + pass + + # Guard: avoid calling retrieval tools with unresolved placeholder IDs + # Try to resolve placeholders for common cases (e.g., projects_retrieve(project_id="") ) + try: + if tool_name == "projects_retrieve" and isinstance(tool_args, dict): + proj_id = tool_args.get("project_id") + if isinstance(proj_id, str): + m = re.search(r"]+)>", proj_id) + if m: + _etype, _ename = m.groups() + if _etype == "project" and isinstance(_ename, str) and _ename.strip(): + # Try resolving via search_project_by_name in the same planning pass + _search_tool = next((t for t in combined_tools if getattr(t, "name", "") == "search_project_by_name"), None) + if _search_tool is not None: + try: + # Invoke search and parse id from formatted result + _res = await ( + _search_tool.ainvoke({"name": _ename}) + if hasattr(_search_tool, "ainvoke") + else _search_tool.invoke({"name": _ename}) + ) + _proj_id = None + if isinstance(_res, str) and "Result:" in _res: + try: + _section = _res.split("Result:")[-1].strip() + _dict = ast.literal_eval(_section) + if isinstance(_dict, dict): + _proj_id = _dict.get("id") + except Exception: + _proj_id = None + # If resolved, replace placeholder and continue normally + if isinstance(_proj_id, str) and _proj_id: + tool_args = dict(tool_args) + tool_args["project_id"] = _proj_id + else: + # Could not resolve now; skip API call to avoid 404 and return an informative message + + msg = ( + f"Skipping projects_retrieve: project '{_ename}' is not found yet. " + f"If this project is being created in this plan, its details will be retrievable after execution." + ) + tool_message = ToolMessage(content=msg, tool_call_id=tool_id) + # Build a minimal flow step marking the retrieval as skipped + flow_step = { + "step_order": current_step, + "step_type": FlowStepType.TOOL, + "tool_name": tool_name, + "content": msg, + "execution_data": { + "args": tool_args, + "tool_id": tool_id, + "skipped_due_to_placeholder": True, + }, + "is_executed": False, + "is_planned": False, + "execution_success": ExecutionStatus.PENDING, + } + return flow_step, tool_message, current_step + 1, tool_query, ExecutionStatus.PENDING, None + except Exception: + # On any resolver failure, fall through to skip to avoid 404s + + msg = ( + f"Skipping projects_retrieve: unable to resolve placeholder for '{_ename}'. " + f"Will retrieve after the project exists." + ) + tool_message = ToolMessage(content=msg, tool_call_id=tool_id) + flow_step = { + "step_order": current_step, + "step_type": FlowStepType.TOOL, + "tool_name": tool_name, + "content": msg, + "execution_data": { + "args": tool_args, + "tool_id": tool_id, + "skipped_due_to_placeholder": True, + }, + "is_executed": False, + "is_planned": False, + "execution_success": ExecutionStatus.PENDING, + } + return flow_step, tool_message, current_step + 1, tool_query, ExecutionStatus.PENDING, None + except Exception: + # Non-fatal; continue with normal execution + pass + + tool_func = next((t for t in combined_tools if getattr(t, "name", "") == tool_name), None) + result = None + execution_success = ExecutionStatus.SUCCESS + execution_error = None + + if tool_func: + try: + if hasattr(tool_func, "ainvoke"): + result = await tool_func.ainvoke(tool_args) + else: + result = tool_func.invoke(tool_args) + except Exception as tool_error: + result = f"Error: {str(tool_error)}" + execution_success = ExecutionStatus.FAILED + execution_error = str(tool_error) + else: + result = f"Tool {tool_name} not found" + + # Only create ToolMessage if we have both tool_name and tool_id + # tool_id must match a tool_call from the previous assistant message + if tool_name and tool_id: + # Extract message from dict if result is structured, otherwise use string representation + if isinstance(result, dict): + # Format as "message\n\nResult: {data}" so format_tool_message_for_display can clean it for users + # while the LLM gets the full structured data with UUIDs + message = result.get("message", "") + # If there's a 'data' field, use it; otherwise omit the Result section (simpler format) + if "data" in result and result["data"]: + content = f"{message}\n\nResult: {json.dumps(result["data"], ensure_ascii=False)}" + else: + # No data field, just use the message + content = message + else: + content = str(result) + tool_message = ToolMessage(content=content, tool_call_id=tool_id) + else: + tool_message = None + if tool_name and not tool_id: + log.warning(f"Missing tool_call_id for tool {tool_name}, skipping ToolMessage creation") + + # Build execution data + + enhanced_execution_data: Dict[str, Any] = { + "args": tool_args, + "retrieval_result": str(result), + "tool_query": tool_query, + "execution_timestamp": datetime.datetime.utcnow().isoformat(), + } + try: + if hasattr(result, "__dict__"): + enhanced_execution_data["structured_result"] = { + k: v for k, v in result.__dict__.items() if not str(k).startswith("_") and isinstance(v, (str, int, float, bool, list, dict)) + } + elif isinstance(result, dict): + enhanced_execution_data["structured_result"] = result + elif isinstance(result, list) and len(result) > 0: + enhanced_execution_data["result_count"] = len(result) + enhanced_execution_data["result_preview"] = result[:3] if len(result) > 3 else result + except Exception: + pass + + flow_step = { + "step_order": current_step, + "step_type": FlowStepType.TOOL, + "tool_name": tool_name, + "content": standardize_flow_step_content(result, FlowStepType.TOOL), + "execution_data": enhanced_execution_data, + "is_executed": False, + "is_planned": False, + "execution_success": execution_success, + "execution_error": execution_error, + } + return flow_step, tool_message, current_step + 1, tool_query, execution_success, execution_error + + +# ------------------------------ +# Phase 2 initialization helpers +# ------------------------------ + + +async def build_method_executor_and_context( + *, + chatbot_instance, + user_id, + workspace_id: str, + project_id: Optional[str], + conversation_history: List[Any], + user_meta: Dict[str, Any], + is_project_chat: Optional[bool], + chat_id, + db, +) -> Tuple[Any, Dict[str, Any], str]: + """Build method executor and action context for Phase 2. + + Returns: (method_executor, context_dict, final_workspace_slug) + """ + + from pi.app.api.v1.helpers.plane_sql_queries import get_project_id_from_identifier + from pi.app.api.v1.helpers.plane_sql_queries import get_workspace_slug + + # Get OAuth token for method executor + access_token = await chatbot_instance._get_oauth_token_for_user(db, user_id, workspace_id) + + # Build workspace context + final_workspace_slug = await get_workspace_slug(workspace_id) + + # Normalize project_id: resolve identifier to UUID if needed + final_project_id = project_id + try: + proj_str = str(project_id) if project_id else None + is_uuid_like = False + if proj_str: + try: + uuid.UUID(proj_str) + is_uuid_like = True + except Exception: + is_uuid_like = False + + if proj_str and not is_uuid_like: + try: + resolved = await get_project_id_from_identifier(proj_str, str(workspace_id)) + if resolved: + final_project_id = str(resolved) + else: + log.warning( + f"ChatID: {chat_id} - Received non-UUID project_id '{proj_str}'. Could not resolve identifier to UUID; omitting project scope." # noqa: E501 + ) + final_project_id = None + except Exception as _e: + log.warning(f"ChatID: {chat_id} - Failed to resolve project identifier '{proj_str}' to UUID: {_e}. Omitting project scope.") + final_project_id = None + except Exception: + pass + + context = { + "workspace_id": workspace_id, + "workspace_slug": final_workspace_slug, + "project_id": final_project_id, + "user_id": user_id, + "conversation_history": conversation_history, + "user_meta": user_meta, + "is_project_chat": is_project_chat, + } + + # Create method executor + if access_token and access_token.startswith("plane_api_"): + actions_executor = PlaneActionsExecutor(api_key=access_token, base_url=settings.plane_api.HOST) + else: + actions_executor = PlaneActionsExecutor(access_token=access_token, base_url=settings.plane_api.HOST) + + method_executor = MethodExecutor(actions_executor) + + return method_executor, context, final_workspace_slug or "" + + +# ------------------------------ +# Clarification recovery helper +# ------------------------------ + + +async def recover_clarification_categories( + *, + user_meta: Dict[str, Any], + chat_id, + db, +) -> Tuple[bool, List[Dict[str, Optional[str]]], Dict[str, Any]]: + """Check if resuming after clarification and recover previously selected categories. + + Returns: (skip_category_selection, selections_list, clar_ctx) + """ + clar_ctx: Dict[str, Any] = {} + skip_category_selection = False + selections_list: List[Dict[str, Optional[str]]] = [] + + try: + if isinstance(user_meta, dict) and user_meta.get("clarification_context"): + clar_ctx = user_meta.get("clarification_context") or {} + hint_list = clar_ctx.get("category_hints") or [] + + # Always try to recover previously selected categories from the saved clarification step + prev_categories: List[str] = [] + try: + from pi.services.retrievers.pg_store.message import get_tool_results_from_chat_history as _get_steps + + clar_steps = await _get_steps(db=db, chat_id=uuid.UUID(str(chat_id)), tool_name="ask_for_clarification") + if clar_steps: + last = clar_steps[0] + exec_data = getattr(last, "execution_data", {}) or {} + prev_categories = [str(c) for c in (exec_data.get("selected_categories") or []) if c] + except Exception: + prev_categories = [] + + # Union of hints and previously selected categories + union_categories = list({*(str(h) for h in hint_list if h), *prev_categories}) + if union_categories: + skip_category_selection = True + selections_list = [{"category": c, "rationale": "from_clarification_resume"} for c in union_categories] + except Exception: + skip_category_selection = False + + return skip_category_selection, selections_list, clar_ctx + + +async def persist_skip_category_selection_step( + *, + selections_list: Sequence[Dict[str, Optional[str]]], + current_step: int, + enhanced_conversation_history: Optional[str], + query_id, + chat_id, + db, +) -> int: + """Persist a routing step when category selection was skipped due to clarification context.""" + async with get_streaming_db_session() as _subdb: + flow_step_result = await _upsert_message_flow_steps( + message_id=query_id, + chat_id=uuid.UUID(str(chat_id)), + flow_steps=[ + { + "step_order": current_step, + "step_type": FlowStepType.ROUTING.value, + "tool_name": "action_category_router", + "content": standardize_flow_step_content( + [ + {"category": str(sel.get("category")) if isinstance(sel, dict) else str(getattr(sel, "category", ""))} + for sel in selections_list + ], + FlowStepType.ROUTING, + ), + "execution_data": { + "skip_category_selection": True, + "source": "clarification", + "enhanced_conversation_history": enhanced_conversation_history, + }, + } + ], + db=_subdb, + ) + if flow_step_result.get("message") != "success": + log.warning("Failed to record clarification-based routing in database") + return current_step + 1 + + +# ------------------------------ +# Auto-resolution and preflight helpers +# ------------------------------ + + +async def auto_resolve_missing_ids( + *, + tool_name: str, + tool_args: Dict[str, Any], + missing_required: List[str], + combined_tools: List[Any], + workspace_slug: str, +) -> bool: + """Attempt to auto-resolve missing required IDs (esp. project_id) before asking user. + + Mutates tool_args in-place if resolution succeeds. + Returns True if at least one field was resolved. + """ + + def _extract_placeholder_name(val: str) -> tuple[str | None, str | None]: + if isinstance(val, str) and ""): + try: + inner = val.strip("<>") + parts = inner.split(":", 1) + left = parts[0].strip() + right = parts[1].strip() + etype = left.split()[-1].strip() + return etype, right + except Exception: + return None, None + return None, None + + async def _auto_resolve_project_id_if_needed() -> bool: + nonlocal tool_args + pid_val = tool_args.get("project_id") if isinstance(tool_args, dict) else None + etype, pname = _extract_placeholder_name(pid_val) if isinstance(pid_val, str) else (None, None) + if pname and (etype == "project" or etype is None): + resolver = next((t for t in combined_tools if getattr(t, "name", "") == "search_project_by_name"), None) + if resolver: + try: + res = await resolver.ainvoke({"name": pname, "workspace_slug": workspace_slug}) + m = re.search(r"\n\nResult:\s*(\{[\s\S]*?\})", str(res)) + if m: + try: + parsed = ast.literal_eval(m.group(1)) + if isinstance(parsed, dict): + cand = parsed.get("id") or parsed.get("project_id") + if isinstance(cand, str): + tool_args["project_id"] = cand + return True + except Exception: + pass + except Exception: + return False + # If not placeholder but a non-UUID string, try extracting a quoted name from the value + if isinstance(pid_val, str): + uuid_like = re.match(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", pid_val, flags=re.IGNORECASE) + if not uuid_like: + m_quote = re.search(r"'([^']+)'|\"([^\"]+)\"", pid_val) + candidate = None + if m_quote: + candidate = m_quote.group(1) or m_quote.group(2) + if not candidate and len(pid_val) <= 80: + candidate = pid_val.strip() + if candidate: + resolver = next((t for t in combined_tools if getattr(t, "name", "") == "search_project_by_name"), None) + if resolver: + try: + res = await resolver.ainvoke({"name": candidate, "workspace_slug": workspace_slug}) + m = re.search(r"\n\nResult:\s*(\{[\s\S]*?\})", str(res)) + if m: + try: + parsed = ast.literal_eval(m.group(1)) + if isinstance(parsed, dict): + cand = parsed.get("id") or parsed.get("project_id") + if isinstance(cand, str): + tool_args["project_id"] = cand + return True + except Exception: + pass + except Exception: + return False + return False + + try: + if "project_id" in (missing_required or []): + did_resolve = await _auto_resolve_project_id_if_needed() + if not did_resolve and tool_name == "workitems_update": + # Derive project_id from issue_id when updating a work-item + try: + issue_id_val = tool_args.get("issue_id") if isinstance(tool_args, dict) else None + if isinstance(issue_id_val, str) and re.match( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", issue_id_val, flags=re.IGNORECASE + ): + from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact + + ident_details = await get_issue_identifier_for_artifact(issue_id_val) + if ident_details and isinstance(ident_details, dict): + pid = ident_details.get("project_id") + if isinstance(pid, str): + tool_args["project_id"] = pid + did_resolve = True + except Exception: + pass + return did_resolve + except Exception: + pass + return False + + +async def handle_preflight_clarification( + *, + tool_name: str, + tool_args: Dict[str, Any], + action_context: Dict[str, Any], + missing_required: List[str], + method_executor, + workspace_slug: str, + chat_id, + tool_id: str, + current_step: int, + combined_tool_query: str, + is_project_chat: Optional[bool], + built_categories: List[str], + all_method_tools: List[Any], + combined_tools: List[Any], + query_id, + db, +) -> Tuple[bool, Optional[Dict[str, Any]], Optional[Any], Optional[Dict[str, Any]], int]: + """Handle preflight missing fields check, create clarification, persist step and record. + + Returns: (clarification_requested, clarification_payload, tool_message, flow_step, next_step) + """ + + clarification_result = await handle_missing_required_fields( + tool_name=tool_name, + tool_args=tool_args, + action_context=action_context, + missing_required=missing_required, + method_executor=method_executor, + workspace_slug=workspace_slug, + chat_id=chat_id, + tool_id=tool_id, + current_step=current_step, + combined_tool_query=combined_tool_query, + is_project_chat=is_project_chat, + ) + + if not clarification_result: + return False, None, None, None, current_step + + clarification_payload = clarification_result["clarification_payload"] + clarification_tool_message = clarification_result.get("tool_message") + flow_step = clarification_result["flow_step"] + + # Persist message_clarifications row + try: + from pi.services.retrievers.pg_store.clarifications import create_clarification + + clar_id = await create_clarification( + db, + chat_id=uuid.UUID(str(chat_id)), + message_id=uuid.UUID(str(query_id)), + kind="action", + original_query=combined_tool_query, + payload=clarification_payload or {}, + categories=[str(c) for c in (built_categories or [])], + method_tool_names=[getattr(t, "name", "") for t in (all_method_tools or [])], + bound_tool_names=[getattr(t, "name", "") for t in (combined_tools or [])], + ) + log.info(f"ChatID: {chat_id} - Created clarification record (preflight): id={clar_id}") + except Exception as e: + log.warning(f"ChatID: {chat_id} - Failed to create clarification record (preflight): {e}") + + return True, clarification_payload, clarification_tool_message, flow_step, current_step + 1 + + +# ------------------------------ +# Final response builders +# ------------------------------ + + +async def build_and_stream_final_response( + *, + response, + current_step: int, + iteration_count: int, + chat_id, +) -> Tuple[List[str], int, Optional[Dict[str, Any]]]: + """Build final response chunks for when actions have been planned. + + Returns: (final_response_chunks, next_step, optional_step) + """ + + final_response_chunks = [] + + if hasattr(response, "content") and response.content: + content = str(response.content).strip() + if content: + final_response_chunks.append(content + "\n") + return final_response_chunks, current_step, None + + # Fallback if no content provided + content = "Planned these actions for you" + final_response_chunks.append(content + "\n") + return final_response_chunks, current_step, None + + +# ------------------------------ +# Planner summary helper +# ------------------------------ +def build_planner_summary_step( + *, + current_step: int, + iteration_count: int, + planned_actions_count: int, + loop_warning_detected: bool, + tool_calls_count: int, + exited_due_to_max_iterations: bool | None = None, +) -> tuple[dict, int]: + """Create a standardized planner_summary flow step and return (step, next_step).""" + execution_data: Dict[str, Any] = { + "iteration_count": iteration_count, + "planned_actions_count": planned_actions_count, + "loop_warning_detected": loop_warning_detected, + "tool_calls_count": tool_calls_count, + } + + if exited_due_to_max_iterations is not None: + execution_data["exited_due_to_max_iterations"] = bool(exited_due_to_max_iterations) + + step = { + "step_order": current_step, + "step_type": FlowStepType.TOOL, + "tool_name": "planner_summary", + "content": "Method planning summary", + "execution_data": execution_data, + "is_planned": False, + "is_executed": False, + "execution_success": ExecutionStatus.SUCCESS, + } + return step, current_step + 1 + + +def selected_action_categories_display(selections_list: List[Union["ActionCategorySelection", Dict[str, Optional[str]]]]) -> str: + """Display the selected action categories in a structured format.""" + cats = [] + reasons = [] + bullets: list[str] = [] + for _sel in selections_list: + if isinstance(_sel, dict): + _cat = str(_sel.get("category") or "").strip() + _rat = _sel.get("rationale") + else: + _cat = str(getattr(_sel, "category", "")).strip() + _rat = getattr(_sel, "rationale", None) + # Convert internal category to a user-friendly label + _cat_display = category_display_name(_cat) + if _cat: + cats.append(_cat) + # build per-category bullet with rationale if present + if _rat: + _rat_s = str(_rat).strip() + reasons.append(_rat_s) + bullets.append(f"- {_cat_display}: {_rat_s}") + else: + bullets.append(f"- {_cat_display}") + + cats = [c for c in cats if c] + + # Structured, multi-line block to show in thinking bubble + # Build display list for selected areas + cats_display = [category_display_name(c) for c in cats] + sub = f"{", ".join(cats_display)}" if cats_display else " -" + details = "\n".join(bullets) if bullets else "" + + if details: + sub += f"\n{details}" + content = f"{sub}\n\n" + + return content diff --git a/apps/pi/pi/services/chat/helpers/context_manager.py b/apps/pi/pi/services/chat/helpers/context_manager.py index 6f037f4f13..8a025d5585 100644 --- a/apps/pi/pi/services/chat/helpers/context_manager.py +++ b/apps/pi/pi/services/chat/helpers/context_manager.py @@ -1,5 +1,9 @@ """Chat context initialization and history management.""" +from typing import Literal +from typing import Optional +from typing import cast + from pi import logger log = logger.getChild(__name__) @@ -20,8 +24,15 @@ async def initialize_chat_context(chatbot_instance, data, chat_exists, db): # This backward compatibility code no longer does workspace resolution is_focus_enabled = data.workspace_in_context + + # Use new polymorphic fields if available, otherwise fall back to legacy fields + focus_entity_type = getattr(data, "focus_entity_type", None) + focus_entity_id = getattr(data, "focus_entity_id", None) focus_project_id = data.project_id or None focus_workspace_id = data.workspace_id or None + mode_raw = getattr(data, "mode", "ask") or "ask" + # Ensure mode is one of the valid literal values + mode: Optional[Literal["ask", "build"]] = cast(Literal["ask", "build"], mode_raw if mode_raw in ("ask", "build") else "ask") if is_new: # For new chats, the chat record should already exist from initialize-chat endpoint @@ -49,8 +60,11 @@ async def initialize_chat_context(chatbot_instance, data, chat_exists, db): chat_id=chat_id, db=db, is_focus_enabled=is_focus_enabled, + focus_entity_type=focus_entity_type, + focus_entity_id=focus_entity_id, focus_project_id=focus_project_id, focus_workspace_id=focus_workspace_id, + mode=mode, ) if user_chat_preference_result["message"] != "success": return None, "An unexpected error occurred. Please try again" diff --git a/apps/pi/pi/services/chat/helpers/flow_tracking.py b/apps/pi/pi/services/chat/helpers/flow_tracking.py new file mode 100644 index 0000000000..1ec682d50c --- /dev/null +++ b/apps/pi/pi/services/chat/helpers/flow_tracking.py @@ -0,0 +1,784 @@ +"""Decorators for automatic flow step tracking and token management.""" + +import asyncio +import functools +import json +import uuid +from typing import Any +from typing import Callable +from typing import Dict +from typing import List +from typing import Optional + +from pi import logger +from pi.app.models.enums import ExecutionStatus +from pi.app.models.enums import FlowStepType +from pi.app.models.enums import MessageMetaStepType +from pi.core.db.plane_pi.lifecycle import get_streaming_db_session +from pi.services.chat.utils import standardize_flow_step_content +from pi.services.retrievers.pg_store.message import upsert_message_flow_steps + +log = logger.getChild(__name__) + + +def track_token_usage(step_type: MessageMetaStepType): + """ + Decorator to automatically set up token tracking context for LLM calls. + + Usage: + @track_token_usage(MessageMetaStepType.TOOL_ORCHESTRATION) + async def my_llm_function(self, llm_with_tools, messages, query_id, db, chat_id): + # Token tracking is automatically configured + return await llm_with_tools.ainvoke(messages) + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(*args, **kwargs): + # Extract self, query_id, db, chat_id from args/kwargs + # Assuming standard signature: (self, llm, messages, query_id, db, chat_id, ...) + args[0] if args else None + llm = args[1] if len(args) > 1 else kwargs.get("llm") + query_id = kwargs.get("query_id") or (args[3] if len(args) > 3 else None) + db = kwargs.get("db") or (args[4] if len(args) > 4 else None) + chat_id = kwargs.get("chat_id") or (args[5] if len(args) > 5 else None) + + # Set tracking context if llm supports it + if llm and hasattr(llm, "set_tracking_context"): + llm.set_tracking_context(query_id, db, step_type, chat_id=str(chat_id) if chat_id else None) + + return await func(*args, **kwargs) + + return wrapper + + return decorator + + +def auto_persist_flow_steps( + step_type: FlowStepType, + tool_name: Optional[str] = None, + extract_content: Optional[Callable] = None, + extract_execution_data: Optional[Callable] = None, + timeout: float = 2.0, + on_error: str = "log", # "log", "raise", or "ignore" +): + """ + Decorator to automatically persist flow steps after function execution. + + Args: + step_type: The type of flow step (TOOL, ROUTING, etc.) + tool_name: Static tool name, or None to extract from result + extract_content: Optional function to extract content from result + extract_execution_data: Optional function to extract execution_data from result + timeout: Timeout for persistence operation + on_error: How to handle persistence errors ("log", "raise", "ignore") + + Usage: + @auto_persist_flow_steps( + step_type=FlowStepType.TOOL, + extract_content=lambda result: result.get('formatted_output'), + extract_execution_data=lambda result: result.get('metadata') + ) + async def execute_tool(self, tool, query, query_id, chat_id, step_order, db): + # Your tool execution logic + return {"formatted_output": "...", "metadata": {...}} + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(*args, **kwargs): + # Execute the original function + result = await func(*args, **kwargs) + + # Extract required parameters from args/kwargs + # Assuming: (self, ..., query_id, chat_id, step_order, db, ...) + query_id = kwargs.get("query_id") + chat_id = kwargs.get("chat_id") + step_order = kwargs.get("step_order", 1) + db = kwargs.get("db") + + if not all([query_id, chat_id, db]): + if on_error == "raise": + raise ValueError("Missing required parameters for flow step persistence") + if on_error == "log": + log.warning("Skipping flow step persistence: missing query_id, chat_id, or db") + return result + + # Extract content and execution_data + content = extract_content(result) if extract_content else str(result) + execution_data = extract_execution_data(result) if extract_execution_data else {} + + # Determine tool_name (static or from result) + actual_tool_name = tool_name + if not actual_tool_name and isinstance(result, dict): + actual_tool_name = result.get("tool_name") + + # Build flow step + flow_step = { + "step_order": step_order, + "step_type": step_type.value, + "tool_name": actual_tool_name, + "content": standardize_flow_step_content(content, step_type), + "execution_data": execution_data, + } + + # Persist asynchronously with timeout + try: + # Ensure query_id and chat_id are UUIDs + if not query_id or not chat_id: + if on_error == "raise": + raise ValueError("Missing required parameters for flow step persistence") + if on_error == "log": + log.warning("Skipping flow step persistence: missing query_id or chat_id") + return result + + query_id_uuid = query_id if isinstance(query_id, uuid.UUID) else uuid.UUID(str(query_id)) + chat_id_uuid = chat_id if isinstance(chat_id, uuid.UUID) else uuid.UUID(str(chat_id)) + + async with get_streaming_db_session() as _db: + await asyncio.wait_for( + upsert_message_flow_steps( + message_id=query_id_uuid, + chat_id=chat_id_uuid, + flow_steps=[flow_step], + db=_db, + ), + timeout=timeout, + ) + except asyncio.TimeoutError: + if on_error == "log": + log.warning(f"Flow step persistence timed out for {func.__name__}") + elif on_error == "raise": + raise + except Exception as e: + if on_error == "log": + log.warning(f"Flow step persistence failed for {func.__name__}: {e}") + elif on_error == "raise": + raise + + return result + + return wrapper + + return decorator + + +class FlowStepCollector: + """ + Context manager for collecting flow steps during execution and persisting them in bulk. + + Usage: + async with FlowStepCollector(query_id, chat_id, db) as collector: + collector.add_step( + step_order=1, + step_type=FlowStepType.TOOL, + tool_name="structured_db_tool", + content="...", + execution_data={...} + ) + # Steps are automatically persisted on exit + """ + + def __init__(self, query_id: uuid.UUID, chat_id: str, db: Any, timeout: float = 2.0, on_error: str = "log"): + self.query_id = query_id + self.chat_id = chat_id + self.db = db + self.timeout = timeout + self.on_error = on_error + self.steps: List[Dict[str, Any]] = [] + + def add_step( + self, + step_order: int, + step_type: FlowStepType, + tool_name: Optional[str] = None, + content: Any = None, + execution_data: Optional[Dict[str, Any]] = None, + **kwargs, + ): + """Add a flow step to the collection.""" + step = { + "step_order": step_order, + "step_type": step_type.value if isinstance(step_type, FlowStepType) else step_type, + "tool_name": tool_name, + "content": standardize_flow_step_content(content, step_type) if content else "", + "execution_data": execution_data or {}, + **kwargs, + } + self.steps.append(step) + + async def __aenter__(self): + """Enter the context manager.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Exit and persist all collected steps.""" + if not self.steps: + return + + try: + # Convert chat_id to UUID (it's typed as str) + chat_id_uuid = uuid.UUID(self.chat_id) + + async with get_streaming_db_session() as _db: + await asyncio.wait_for( + upsert_message_flow_steps( + message_id=self.query_id, + chat_id=chat_id_uuid, + flow_steps=self.steps, + db=_db, + ), + timeout=self.timeout, + ) + except asyncio.TimeoutError: + if self.on_error == "log": + log.warning(f"Flow step collection persistence timed out ({len(self.steps)} steps)") + elif self.on_error == "raise": + raise + except Exception as e: + if self.on_error == "log": + log.warning(f"Flow step collection persistence failed: {e}") + elif self.on_error == "raise": + raise + + +def persist_tool_selection_steps(func: Callable) -> Callable: + """ + Decorator to persist tool selection and optional LLM reasoning steps around an LLM call. + + The wrapped function must be async and accept keyword-only args: + - query_id, chat_id, db, current_step, include_context (bool), + enhanced_conversation_history (str|None), enhanced_query_for_processing (str|None), tools (list|None) + + It must return an AIMessage (or compatible) which may include tool_calls and content. + + Returns: (ai_message, new_current_step) + """ + + @functools.wraps(func) + async def wrapper(*args, **kwargs): + query_id = kwargs.get("query_id") + chat_id = kwargs.get("chat_id") + db = kwargs.get("db") + current_step = kwargs.get("current_step", 1) + include_context = bool(kwargs.get("include_context", False)) + enhanced_conversation_history = kwargs.get("enhanced_conversation_history") + enhanced_query_for_processing = kwargs.get("enhanced_query_for_processing") + tools = kwargs.get("tools") or [] + + ai_message = await func(*args, **kwargs) + steps: List[Dict[str, Any]] = [] + + try: + # Optionally store orchestration context + if ( + include_context + and enhanced_conversation_history + and isinstance(enhanced_conversation_history, str) + and enhanced_conversation_history.strip() + ): + steps.append({ + "step_order": current_step, + "step_type": FlowStepType.TOOL.value, + "tool_name": "tool_orchestration_context", + "content": "Context used for tool orchestration", + "execution_data": {"enhanced_conversation_history": enhanced_conversation_history}, + "is_planned": False, + "is_executed": False, + }) + current_step += 1 + + # Persist tool selection + tool_calls = getattr(ai_message, "tool_calls", None) + if tool_calls: + selected_tool_calls = [{"name": tc.get("name"), "args": tc.get("args", {}), "id": tc.get("id", "")} for tc in tool_calls] + steps.append({ + "step_order": current_step, + "step_type": FlowStepType.TOOL.value, + "tool_name": "tool_selection", + "content": standardize_flow_step_content({"selected_tools": selected_tool_calls}, FlowStepType.TOOL), + "execution_data": { + "selected_tools": selected_tool_calls, + "available_tools": [getattr(t, "name", "") for t in tools], + "query": enhanced_query_for_processing, + }, + "is_planned": False, + "is_executed": False, + }) + current_step += 1 + + # Persist LLM reasoning content if present + reasoning_text = str(getattr(ai_message, "content", "") or "").strip() + if reasoning_text: + steps.append({ + "step_order": current_step, + "step_type": FlowStepType.TOOL.value, + "tool_name": "llm_reasoning", + "content": reasoning_text, + "execution_data": {"reasoning": reasoning_text}, + "is_planned": False, + "is_executed": False, + }) + current_step += 1 + + if steps and query_id and chat_id and db: + async with get_streaming_db_session() as _db: + await upsert_message_flow_steps( + message_id=query_id, + chat_id=chat_id, + flow_steps=steps, + db=_db, + ) + except Exception as e: + log.warning(f"Failed to persist tool selection/llm steps: {e}") + + return ai_message, current_step + + return wrapper + + +def persist_tool_execution_step(func: Callable) -> Callable: + """ + Decorator to persist a tool execution step (success or error). + + The wrapped function must be async and accept keyword-only args: + - query_id, chat_id, db, current_step, extract_facts_fn (callable|None) + + It must return the tool_result. The decorator will persist the step and return (tool_result, new_current_step). + """ + + @functools.wraps(func) + async def wrapper(*args, **kwargs): + query_id = kwargs.get("query_id") + chat_id = kwargs.get("chat_id") + db = kwargs.get("db") + current_step = kwargs.get("current_step", 1) + extract_facts_fn = kwargs.get("extract_facts_fn") + + # Expect first two positional args to include tool_name and tool_args via signature + # Safer: rely on kwargs provided by caller for tool_name/tool_args + tool_name = kwargs.get("tool_name") or (args[1] if len(args) > 1 else "") + tool_args = kwargs.get("tool_args") or (args[2] if len(args) > 2 else {}) + + try: + result = await func(*args, **kwargs) + + facts: Dict[str, Any] = {} + if callable(extract_facts_fn): + try: + facts = extract_facts_fn(tool_name, result) or {} + except Exception: + facts = {} + + # Normalize execution result for persistence (used by chat history reconstruction) + # Prefer structured JSON when possible, otherwise fall back to string + execution_result: str = "" + structured_result: Dict[str, Any] | List[Any] | None = None + try: + if isinstance(result, (dict, list)): + structured_result = result + # Keep a string copy to assist display formatters that expect strings + import json as _json + + execution_result = _json.dumps(result, default=str) + else: + execution_result = str(result) + except Exception: + execution_result = str(result) + structured_result = None + + execution_data: Dict[str, Any] = { + "tool_args": tool_args, + "tool_name": tool_name, + "execution_status": "success", + } + if facts: + execution_data["facts"] = facts + # Persist result in execution_data so retrieval can reconstruct external reasoning + if execution_result: + execution_data["execution_result"] = execution_result + if structured_result is not None: + execution_data["structured_result"] = structured_result + + step = { + "step_order": current_step, + "step_type": FlowStepType.TOOL.value, + "tool_name": tool_name, + "content": standardize_flow_step_content(execution_data, FlowStepType.TOOL), + "execution_data": execution_data, + "is_planned": False, + "is_executed": True, + # execution_success is persisted via upsert_message_flow_steps when provided. + # We set it to SUCCESS explicitly to avoid PENDING default on retrieval. + "execution_success": ExecutionStatus.SUCCESS.value, + } + + if query_id and chat_id and db: + async with get_streaming_db_session() as _db: + await upsert_message_flow_steps( + message_id=query_id, + chat_id=chat_id, + flow_steps=[step], + db=_db, + ) + return result, current_step + 1 + except Exception as e: + # Persist error step + error_step = { + "step_order": current_step, + "step_type": FlowStepType.TOOL.value, + "tool_name": tool_name, + "content": standardize_flow_step_content( + { + "tool_args": tool_args, + "execution_status": "error", + "execution_error": str(e), + }, + FlowStepType.TOOL, + ), + "execution_data": { + "tool_args": tool_args, + "execution_status": "error", + "execution_error": str(e), + }, + "is_planned": False, + "is_executed": True, + "execution_status": ExecutionStatus.FAILED.value, + } + try: + if query_id and chat_id and db: + async with get_streaming_db_session() as _db: + await upsert_message_flow_steps( + message_id=query_id, + chat_id=chat_id, + flow_steps=[error_step], + db=_db, + ) + except Exception as pe: + log.warning(f"Failed to persist error step: {pe}") + raise + + return wrapper + + +# ------------------------------ +# Pre-decorated helper functions +# ------------------------------ + + +async def orchestrate_llm_step(llm_with_tools, messages, **kwargs): + """Invoke LLM with tools and persist selection/reasoning using decorator.""" + query_id = kwargs.get("query_id") + chat_id = kwargs.get("chat_id") + db = kwargs.get("db") + current_step = kwargs.get("current_step", 1) + include_context = bool(kwargs.get("include_context", False)) + enhanced_conversation_history = kwargs.get("enhanced_conversation_history") + enhanced_query_for_processing = kwargs.get("enhanced_query_for_processing") + tools = kwargs.get("tools") or [] + + # Re-sync current_step with DB to avoid collisions with independently persisted steps + try: + if query_id and chat_id and db: + async with get_streaming_db_session() as _db: + from sqlalchemy import func # type: ignore + from sqlalchemy import select # type: ignore + + from pi.app.models import MessageFlowStep # lazy import to avoid cycles + + stmt: Any = select(func.max(MessageFlowStep.step_order)).where( # type: ignore[arg-type] + MessageFlowStep.message_id == query_id + ) + result = await _db.execute(stmt) + max_step = result.scalar_one_or_none() + next_step = (max_step or 0) + 1 + if current_step < next_step: + current_step = next_step + except Exception: + # best-effort; proceed with provided current_step on error + pass + + # Execute LLM call + ai_message = await llm_with_tools.ainvoke(messages) + steps: List[Dict[str, Any]] = [] + + try: + # Optionally store orchestration context + if ( + include_context + and enhanced_conversation_history + and isinstance(enhanced_conversation_history, str) + and enhanced_conversation_history.strip() + ): + steps.append({ + "step_order": current_step, + "step_type": FlowStepType.TOOL.value, + "tool_name": "tool_orchestration_context", + "content": "Context used for tool orchestration", + "execution_data": {"enhanced_conversation_history": enhanced_conversation_history}, + "is_planned": False, + "is_executed": False, + }) + current_step += 1 + log.info("orchestrate_llm_step: Added tool_orchestration_context step") + + # Persist tool selection + tool_calls = getattr(ai_message, "tool_calls", None) + if tool_calls: + selected_tool_calls = [{"name": tc.get("name"), "args": tc.get("args", {}), "id": tc.get("id", "")} for tc in tool_calls] + steps.append({ + "step_order": current_step, + "step_type": FlowStepType.TOOL.value, + "tool_name": "tool_selection", + "content": standardize_flow_step_content({"selected_tools": selected_tool_calls}, FlowStepType.TOOL), + "execution_data": { + "selected_tools": selected_tool_calls, + "available_tools": [getattr(t, "name", "") for t in tools], + "query": enhanced_query_for_processing, + }, + "is_planned": False, + "is_executed": False, + }) + current_step += 1 + log.info(f"orchestrate_llm_step: Added tool_selection step with {len(tool_calls)} tools") + + # Persist LLM reasoning content if present + reasoning_text = str(getattr(ai_message, "content", "") or "").strip() + if reasoning_text: + steps.append({ + "step_order": current_step, + "step_type": FlowStepType.TOOL.value, + "tool_name": "llm_reasoning", + "content": reasoning_text, + "execution_data": {"reasoning": reasoning_text}, + "is_planned": False, + "is_executed": False, + }) + current_step += 1 + log.info(f"orchestrate_llm_step: Added llm_reasoning step (content length: {len(reasoning_text)})") + + log.info(f"orchestrate_llm_step: Total steps to persist: {len(steps)}, query_id={query_id}, chat_id={chat_id}, db={db is not None}") + if steps and query_id and chat_id and db: + log.info(f"orchestrate_llm_step: Persisting {len(steps)} steps...") + async with get_streaming_db_session() as _db: + result = await upsert_message_flow_steps( + message_id=query_id, + chat_id=chat_id, + flow_steps=steps, + db=_db, + ) + log.info(f"orchestrate_llm_step: Persistence result: {result.get("message") if isinstance(result, dict) else result}") + else: + log.warning( + f"orchestrate_llm_step: Skipping persistence - steps={len(steps)}, has_query_id={query_id is not None}, has_chat_id={chat_id is not None}, has_db={db is not None}" # noqa: E501 + ) + except Exception as e: + log.warning(f"Failed to persist tool selection/llm steps: {e}", exc_info=True) + + return ai_message, current_step + + +@persist_tool_execution_step +async def execute_tool_step(tool_to_execute, tool_name: str, tool_args: Dict[str, Any], **kwargs): + """Execute a single tool and persist success/error using decorator.""" + return await tool_to_execute.ainvoke(tool_args) + + +async def check_and_build_clarification(*, tools: List[Any], tool_name: str, tool_args: Dict[str, Any]) -> tuple[Dict[str, Any], Any]: + """Execute ask_for_clarification tool and return (payload, raw_result).""" + # Find tool + tool_to_execute = next((t for t in tools if getattr(t, "name", None) == tool_name), None) + try: + result = await tool_to_execute.ainvoke(tool_args) if tool_to_execute else "{}" + except Exception: + result = "{}" + + # Parse payload + payload: Dict[str, Any] = {} + try: + parsed = json.loads(result) if isinstance(result, str) else result + if isinstance(parsed, dict): + payload = parsed + else: + payload = {"raw_result": str(result)} + except Exception: + payload = {"reason": tool_args.get("reason", ""), "raw_result": str(result)} + + return payload, result + + +async def store_and_format_clarification( + *, + query_id: uuid.UUID, + chat_id: str, + db: Any, + current_step: int, + tool_name: str, + tool_args: Dict[str, Any], + result: Any, + clarification_payload: Dict[str, Any], + enhanced_query_for_processing: str | None, + tools: List[Any], +) -> tuple[str, int]: + """Persist flow step + message clarification row and return formatted text with next step.""" + # Persist flow step (pending) + next_step = await record_clarification_step( + query_id=query_id, + chat_id=chat_id, + db=db, + current_step=current_step, + tool_name=tool_name, + tool_args=tool_args, + result=result, + ) + + # Persist clarification record for deterministic follow-up handling + try: + from pi.services.retrievers.pg_store.clarifications import create_clarification + + clar_kind = "retrieval" + bound_names = [getattr(t, "name", "") for t in tools] if tools else [] + categories: list[str] = [] + + # Ensure original_query is not None + original_query = enhanced_query_for_processing or "" + + await create_clarification( + db=db, + chat_id=uuid.UUID(str(chat_id)), + message_id=uuid.UUID(str(query_id)), + kind=clar_kind, + original_query=original_query, + payload=clarification_payload or {}, + categories=categories, + method_tool_names=[], + bound_tool_names=bound_names, + ) + except Exception: + # Non-fatal + pass + + # Format clarification text + try: + from .tool_utils import format_clarification_as_text + + formatted_text = format_clarification_as_text(clarification_payload) + except Exception: + formatted_text = json.dumps(clarification_payload, default=str) + + return formatted_text, next_step + + +async def record_clarification_step( + *, + query_id: uuid.UUID, + chat_id: str, + db: Any, + current_step: int, + tool_name: str, + tool_args: Dict[str, Any], + result: Any, +) -> int: + """Persist a clarification flow step as pending and return the next step index.""" + try: + step = { + "step_order": current_step, + "step_type": FlowStepType.TOOL.value, + "tool_name": tool_name, + "content": standardize_flow_step_content( + { + "tool_args": tool_args, + "clarification_result": result, + }, + FlowStepType.TOOL, + ), + "execution_data": { + "tool_args": tool_args, + "clarification_result": result, + }, + "is_planned": False, + "is_executed": False, + "execution_success": ExecutionStatus.PENDING, + } + # Convert chat_id to UUID (it's typed as str) + chat_id_uuid = uuid.UUID(chat_id) + + async with get_streaming_db_session() as _db: + await upsert_message_flow_steps( + message_id=query_id, + chat_id=chat_id_uuid, + flow_steps=[step], + db=_db, + ) + return current_step + 1 + except Exception as e: + log.warning(f"Failed to persist clarification step: {e}") + return current_step + + +# ------------------------------ +# Build-mode planning helpers +# ------------------------------ + + +def build_planned_action_step( + *, + step_order: int, + tool_name: str, + action_summary: Dict[str, Any], + execution_data: Dict[str, Any], +) -> Dict[str, Any]: + """Build a standardized planned action flow step dict (not persisted).""" + return { + "step_order": step_order, + "step_type": FlowStepType.TOOL, + "tool_name": tool_name, + "content": standardize_flow_step_content(action_summary, FlowStepType.TOOL), + "execution_data": execution_data, + "is_executed": False, + "is_planned": True, + "execution_success": ExecutionStatus.PENDING, + } + + +async def persist_planned_action_step( + *, + query_id: uuid.UUID, + chat_id: str, + db: Any, + step_order: int, + tool_name: str, + action_summary: Dict[str, Any], + execution_data: Dict[str, Any], + timeout: float = 2.0, +) -> Dict[str, Any]: + """Build and persist a single planned action flow step; return the step dict. + + This is useful when you want immediate durability for a planned action rather than + deferring to a batched collector. + """ + step = build_planned_action_step( + step_order=step_order, + tool_name=tool_name, + action_summary=action_summary, + execution_data=execution_data, + ) + + try: + chat_id_uuid = uuid.UUID(str(chat_id)) + async with get_streaming_db_session() as _db: + await asyncio.wait_for( + upsert_message_flow_steps( + message_id=query_id, + chat_id=chat_id_uuid, + flow_steps=[step], + db=_db, + ), + timeout=timeout, + ) + except asyncio.TimeoutError: + log.warning("persist_planned_action_step: persistence timed out") + except Exception as e: + log.warning(f"persist_planned_action_step: persistence failed: {e}") + + return step diff --git a/apps/pi/pi/services/chat/helpers/placeholder_orchestrator.py b/apps/pi/pi/services/chat/helpers/placeholder_orchestrator.py new file mode 100644 index 0000000000..18d77bc400 --- /dev/null +++ b/apps/pi/pi/services/chat/helpers/placeholder_orchestrator.py @@ -0,0 +1,546 @@ +""" +Hybrid Placeholder Orchestration System + +This module provides programmatic orchestration of action execution with dependency resolution. +Instead of asking the LLM to handle everything, we use: +- Python for dependency tracking and execution flow control +- Focused LLM calls for extracting specific values from tool results +""" + +import json +import re +from datetime import datetime +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple + +from pydantic import BaseModel +from pydantic import Field + +from pi import logger + +log = logger.getChild(__name__) + + +# Pydantic models for structured extraction +class ExtractedEntity(BaseModel): + """Extracted entity information from execution context.""" + + entity_type: str = Field(..., description="Type of entity (e.g., 'project', 'cycle')") + entity_name: str = Field(..., description="Name of the entity as it appears in context") + entity_id: str = Field(..., description="UUID of the entity (use for *_id fields)") + entity_identifier: Optional[str] = Field(None, description="Short identifier/slug (e.g., 'PROJ', use for project identifier fields)") + + +class PlaceholderResolutionBatch(BaseModel): + """Batch resolution for all placeholders in an action.""" + + resolutions: List[ExtractedEntity] = Field(default_factory=list, description="List of resolved entities") + + +class PlaceholderOrchestrator: + """ + Orchestrates execution of planned actions with smart placeholder resolution. + + Key Features: + - Detects dependencies between actions + - Executes independent actions in parallel + - Resolves placeholders using focused LLM extractions + - Validates extracted values (e.g., UUID format) + """ + + def __init__(self, planned_actions: List[Dict[str, Any]], chatbot, method_executor, context: Dict[str, Any], chat_id: str, message_id: str, db): + """ + Initialize the orchestrator. + + Args: + planned_actions: List of actions to execute, each with tool_name and args + chatbot: PlaneChatBot instance for LLM access and tool building + method_executor: Executor for calling Plane API methods + context: Execution context (workspace_slug, user_id, etc.) + db: Database session for tracking + """ + self.planned_actions = planned_actions + self.chatbot = chatbot + self.method_executor = method_executor + self.context = context + self.db = db + self.chat_id = chat_id + self.message_id = message_id + + # Execution state + self.execution_context: Dict[str, Dict[str, Any]] = {} # Stores tool results + self.results: List[Dict[str, Any]] = [] # Final execution results + self.tools_cache: Dict[str, Any] = {} # Cache of built tools + + # UUID validation pattern + self.uuid_pattern = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + + async def execute_all(self) -> List[Dict[str, Any]]: + """ + Main orchestration loop with dependency resolution. + + Returns: + List of execution results with metadata + """ + log.info(f"Starting orchestrated execution of {len(self.planned_actions)} actions") + log.info(f"\n\nPlanned actions: {self.planned_actions}\n\n") + + remaining = list(self.planned_actions) + iteration = 0 + max_iterations = len(self.planned_actions) * 2 # Prevent infinite loops + + while remaining and iteration < max_iterations: + iteration += 1 + log.info(f"Iteration {iteration}: {len(remaining)} actions remaining") + + # 1. Partition actions into ready vs blocked + ready, blocked = await self._partition_by_readiness(remaining) + + log.info(f"Ready: {len(ready)}, Blocked: {len(blocked)}") + + # 2. Deadlock detection + if not ready and blocked: + error_msg = self._build_deadlock_error(blocked) + log.error(error_msg) + raise RuntimeError(error_msg) + + # 3. Execute all ready actions + for action in ready: + try: + result = await self._execute_action(action) + self.results.append(result) + + # CRITICAL: Only update context if action succeeded + if result.get("success"): + self._update_context(action, result) + else: + log.warning(f"Action {action.get("tool_name")} failed, not updating context") + + remaining.remove(action) + except Exception as e: + log.error(f"Failed to execute action {action.get("tool_name")}: {e}") + # Create failure result + failure_result = { + "tool_name": action.get("tool_name"), + "result": f"❌ Failed to execute: {str(e)}", + "entity_info": None, + "artifact_id": action.get("artifact_id"), + "sequence": len(self.results) + 1, + "artifact_type": action.get("entity_type"), + "executed_at": datetime.utcnow().isoformat(), + "success": False, + "error": str(e), + } + self.results.append(failure_result) + remaining.remove(action) + + # 4. Try to resolve placeholders for blocked actions + for action in blocked: + try: + await self._resolve_placeholders_in_action(action) + except Exception as e: + log.warning(f"Failed to resolve placeholders for {action.get("tool_name")}: {e}") + + if iteration >= max_iterations: + log.warning(f"Reached max iterations ({max_iterations}), stopping") + + log.info(f"Orchestration complete. Executed {len(self.results)} actions successfully") + return self.results + + async def _partition_by_readiness(self, actions: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """ + Split actions into ready (can execute now) vs blocked (has unresolved placeholders). + + Args: + actions: List of actions to partition + + Returns: + Tuple of (ready_actions, blocked_actions) + """ + ready = [] + blocked = [] + + for action in actions: + if await self._has_unresolved_placeholders(action): + blocked.append(action) + else: + ready.append(action) + + return ready, blocked + + async def _has_unresolved_placeholders(self, action: Dict[str, Any]) -> bool: + """ + Check if action has placeholders that cannot yet be resolved from context. + + Args: + action: Action to check + + Returns: + True if action has unresolved placeholders + """ + args = action.get("args", {}) + + for key, value in args.items(): + if self._is_placeholder(value): + # Check if we can resolve this placeholder from current context + entity_type, entity_name = self._parse_placeholder(value) + if not self._can_resolve_from_context(entity_type, entity_name): + return True + + # Check lists for placeholders + if isinstance(value, list): + for item in value: + if self._is_placeholder(item): + entity_type, entity_name = self._parse_placeholder(item) + if not self._can_resolve_from_context(entity_type, entity_name): + return True + + return False + + async def _resolve_placeholders_in_action(self, action: Dict[str, Any]): + """ + Resolve all placeholders in an action's arguments. + + Args: + action: Action with placeholders to resolve + """ + args = action.get("args", {}) + + for key, value in args.items(): + if self._is_placeholder(value): + resolved = await self._resolve_single_placeholder(key, value) + args[key] = resolved + log.info(f"Resolved {key}: {value} -> {resolved}") + + # Handle lists of placeholders + elif isinstance(value, list): + resolved_list = [] + for item in value: + if self._is_placeholder(item): + resolved = await self._resolve_single_placeholder(key, item) + resolved_list.append(resolved) + else: + resolved_list.append(item) + if resolved_list != value: + args[key] = resolved_list + + async def _resolve_single_placeholder(self, field_name: str, placeholder: str) -> str: + """ + Programmatically resolve placeholder by looking up entity in execution context. + + NO LLM CALLS - Pure programmatic lookup for speed, reliability, and cost savings. + + Args: + field_name: Name of the field (e.g., "project_id", "issues") + placeholder: Placeholder string (e.g., "") + + Returns: + Resolved value for this field + + Raises: + ValueError: If entity not found in context or validation fails + """ + entity_type, entity_name = self._parse_placeholder(placeholder) + + 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}" + + # Try exact match first + if lookup_key in self.execution_context: + context_entry = self.execution_context[lookup_key] + elif entity_name in self.execution_context: + # Fallback to name-only lookup + context_entry = self.execution_context[entity_name] + else: + raise ValueError( + f"Entity '{entity_name}' (type: {entity_type}) not found in execution context. " + f"Available entities: {list(self.execution_context.keys())}" + ) + + # Extract entity_info from the stored result + entity_info = context_entry.get("entity_info", {}) + + if not entity_info: + raise ValueError(f"No entity_info found for '{entity_name}'. " f"Stored context entry: {context_entry}") + + # Programmatically select the appropriate field + # LOGIC: Almost all fields are entity references (UUID) except slugs/identifiers + + if field_name in ["workspace_slug", "identifier"]: + # Slug/identifier fields → use entity_identifier (short code like "PROJ") + resolved_value = entity_info.get("entity_identifier") + if not resolved_value: + # Fallback to entity_id if identifier not present + log.warning(f"No entity_identifier for '{entity_name}', using entity_id as fallback") + resolved_value = entity_info.get("entity_id") + else: + # ALL other fields are entity references → use entity_id (UUID) + # This includes: project_id, cycle_id, issue_id, issues, assignees, members, labels, etc. + resolved_value = entity_info.get("entity_id") + if not resolved_value: + raise ValueError(f"No entity_id found for '{entity_name}' in entity_info: {entity_info}") + + if not resolved_value: + raise ValueError(f"Could not extract value for field '{field_name}' from entity '{entity_name}'. " f"Entity info: {entity_info}") + + # Validate the resolved value + self._validate_extracted_value(field_name, resolved_value) + + log.info(f"✅ Resolved {field_name}='{placeholder}' → '{resolved_value}' " f"(from {entity_type}:{entity_name})") + + return resolved_value + + def _build_extraction_prompt(self, entity_type: str, entity_name: str) -> str: + """ + Build a focused prompt for extracting entity information with structured output. + + Args: + entity_type: Type of entity to find (e.g., "project", "cycle") + entity_name: Name of entity to find (e.g., "Motor Bike") + + Returns: + Extraction prompt string requesting structured JSON + """ + # Escape curly braces in the context JSON so LangChain doesn't treat them as variables + context_json = json.dumps(self.execution_context, indent=2) + escaped_context = context_json.replace("{", "{{").replace("}", "}}") + + prompt = f"""You are extracting entity information from tool execution results. + +TASK: Extract complete entity information for a specific entity + +ENTITY TO FIND: {entity_type} named "{entity_name}" + +CONTEXT (Tool execution results): +```json +{escaped_context} +``` + +INSTRUCTIONS: +You must extract ALL of the following fields for the requested entity: +1. entity_type - The type of entity (e.g., "project", "cycle", "workitem") +2. entity_name - The exact name of the entity as it appears in the context +3. entity_id - The UUID of the entity (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) +4. entity_identifier - The short identifier/slug (e.g., "PROJ") if available, null if not + +CRITICAL RULES: +- entity_id is ALWAYS a UUID (36 characters with hyphens) +- entity_identifier is a SHORT code like "MOTO" or "PROJ" (or null if not present) +- DO NOT confuse these two! They are completely different fields! +- Look in the entity_info or entity section of the context + +OUTPUT FORMAT: +Return a JSON object with this exact structure: +```json +{{{{ + "entity_type": "project", + "entity_name": "Motor Bike", + "entity_id": "acf5c262-790b-4a15-bd56-501bb8863968", + "entity_identifier": "MOTO" +}}}} +``` + +IMPORTANT: +- Return ONLY the JSON object, no other text +- You may wrap it in markdown code blocks if needed +- All fields are required except entity_identifier (can be null) +- Make sure the entity_id is the UUID, NOT the identifier! +""" + return prompt + + def _validate_extracted_value(self, field_name: str, value: str): + """ + Validate that extracted value has the correct format. + + Args: + field_name: Name of the field + value: Extracted value to validate + + Raises: + ValueError: If validation fails + """ + # Validate UUID fields + if field_name.endswith("_id"): + if not self._is_valid_uuid(value): + raise ValueError( + f"Field '{field_name}' requires UUID format, but got: '{value}'. " f"This looks like an identifier/slug, not a UUID." + ) + + # Basic presence check + if not value or value.strip() == "": + raise ValueError(f"Extracted value for '{field_name}' is empty") + + def _is_valid_uuid(self, value: str) -> bool: + """Check if value matches UUID format.""" + return bool(self.uuid_pattern.match(value)) + + async def _execute_action(self, action: Dict[str, Any]) -> Dict[str, Any]: + """ + Execute a single action (all placeholders should be resolved at this point). + + Args: + action: Action to execute + + Returns: + Execution result with metadata + """ + tool_name = action["tool_name"] + args = action["args"] + + log.info(f"Executing: {tool_name} with args: {args}") + + # 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 + + # Get the tool + tool = self.tools_cache.get(tool_name) + if not tool: + raise ValueError(f"Tool '{tool_name}' not found") + + # Execute the tool + result = await tool.ainvoke(args) + + if not isinstance(result, dict): + raise ValueError(f"Tool '{tool_name}' must return a dict, got {type(result)}") + + # Build execution result + message = result.get("message") or "" + ok = bool(result.get("ok", True)) + entity_info = result.get("entity") + + execution_result = { + "tool_name": tool_name, + "result": message, + "entity_info": entity_info, + "artifact_id": action.get("artifact_id"), + "sequence": len(self.results) + 1, + "artifact_type": action.get("entity_type"), + "executed_at": datetime.utcnow().isoformat(), + "success": ok, + } + + if not ok: + execution_result["error"] = result.get("error", "Unknown error") + + return execution_result + + def _update_context(self, action: Dict[str, Any], result: Dict[str, Any]): + """ + Store execution result in context for future placeholder resolution. + + CRITICAL: Stores under BOTH the planned name and actual name to handle + cases where the SDK modifies the name on conflict (e.g., "Motor Bike" → "Motor Bike47119"). + + Args: + action: Executed action with original planned args + result: Execution result with actual entity_info + """ + entity_info = result.get("entity_info", {}) + if not entity_info: + return + + entity_type = entity_info.get("entity_type") + actual_name = entity_info.get("entity_name") # Name from API response (may be modified) + + if not entity_type or not actual_name: + return + + # Get the planned name from action args + planned_name = action.get("args", {}).get("name") + + # Build all possible lookup keys + keys = [ + f"{entity_type}:{actual_name}", # e.g., "project:Motor Bike47119" + actual_name, # 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.") + + # Store under all keys + for key in keys: + self.execution_context[key] = result + + log.info(f"✅ Stored in context: {entity_type}:{actual_name} (with {len(keys)} lookup keys)") + + 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. + + Args: + entity_type: Type of entity (e.g., "project") + entity_name: Name of entity (e.g., "Motor Bike") + + Returns: + True if entity is available in context + """ + 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 + + def _is_placeholder(self, value: Any) -> bool: + """Check if value is a placeholder string.""" + return isinstance(value, str) and " Tuple[Optional[str], Optional[str]]: + """ + Parse placeholder string into entity type and name. + + Args: + placeholder: Placeholder string (e.g., "") + + Returns: + Tuple of (entity_type, entity_name), or (None, None) if parse fails + """ + # Pattern: '' + match = re.match(r"", placeholder) + if match: + return match.group(1), match.group(2).strip() + return None, None + + def _build_deadlock_error(self, blocked_actions: List[Dict[str, Any]]) -> str: + """ + Build informative error message for deadlock situations. + + Args: + blocked_actions: Actions that are still blocked + + Returns: + Error message string + """ + placeholders = [] + for action in blocked_actions: + args = action.get("args", {}) + for key, value in args.items(): + if self._is_placeholder(value): + placeholders.append(value) + + available_entities = list(self.execution_context.keys()) + + return ( + f"Deadlock detected: Cannot resolve placeholders.\n" + f"Blocked placeholders: {placeholders}\n" + f"Available entities in context: {available_entities}\n" + f"Possible causes:\n" + f"- Entity was not created successfully\n" + f"- Entity name mismatch between placeholder and actual name\n" + f"- Circular dependency between actions" + ) diff --git a/apps/pi/pi/services/chat/helpers/response_processor.py b/apps/pi/pi/services/chat/helpers/response_processor.py index aff474115f..e82ba76db5 100644 --- a/apps/pi/pi/services/chat/helpers/response_processor.py +++ b/apps/pi/pi/services/chat/helpers/response_processor.py @@ -9,7 +9,7 @@ from pi.services.retrievers.pg_store.message import upsert_message log = logger.getChild(__name__) -async def process_response(base_stream, chat_id, query_id, response_id, switch_llm, db, reasoning=""): +async def process_response(base_stream, chat_id, query_id, response_id, switch_llm, db, reasoning="", source=None): """Process streaming response and store the final result.""" final_response_ = [] @@ -35,6 +35,7 @@ async def process_response(base_stream, chat_id, query_id, response_id, switch_l parent_id=query_id, llm_model=switch_llm, reasoning=reasoning, + source=source, db=db, ) diff --git a/apps/pi/pi/services/chat/helpers/tool_utils.py b/apps/pi/pi/services/chat/helpers/tool_utils.py index 6ea4976dff..b070bb75af 100644 --- a/apps/pi/pi/services/chat/helpers/tool_utils.py +++ b/apps/pi/pi/services/chat/helpers/tool_utils.py @@ -5,8 +5,10 @@ This module intentionally centralizes non-core helpers used by the action execut so that `action_executor.execute_action_with_retrieval` stays lean and readable. """ +import asyncio import logging import re +from collections.abc import AsyncIterator from typing import Any from typing import Dict from typing import List @@ -16,6 +18,86 @@ from typing import Union log = logging.getLogger(__name__) +# build a map of tool name to category +TOOL_NAME_TO_CATEGORY_MAP: Dict[str, Dict[str, str]] = { + # Assets + "assets_create": {"entity_type": "asset", "action_type": "create", "front_facing_name": "Create Asset"}, + "assets_create_user_upload": {"entity_type": "asset", "action_type": "create", "front_facing_name": "Create Asset User Upload"}, + "assets_delete_user": {"entity_type": "asset", "action_type": "delete", "front_facing_name": "Delete Asset User"}, + "assets_get_generic": {"entity_type": "asset", "action_type": "get", "front_facing_name": "Get Asset Generic"}, + "assets_update_generic": {"entity_type": "asset", "action_type": "update", "front_facing_name": "Update Asset Generic"}, + "assets_update_user": {"entity_type": "asset", "action_type": "update", "front_facing_name": "Update Asset User"}, + # Attachments + "attachments_create": {"entity_type": "attachment", "action_type": "create", "front_facing_name": "Create Attachment"}, + "attachments_delete": {"entity_type": "attachment", "action_type": "delete", "front_facing_name": "Delete Attachment"}, + # Comments + "comments_create": {"entity_type": "comment", "action_type": "create", "front_facing_name": "Create Comment"}, + "comments_delete": {"entity_type": "comment", "action_type": "delete", "front_facing_name": "Delete Comment"}, + "comments_update": {"entity_type": "comment", "action_type": "update", "front_facing_name": "Update Comment"}, + # Cycles + "cycles_add_work_items": {"entity_type": "cycle", "action_type": "add", "front_facing_name": "Add Work Items to Cycle"}, + "cycles_archive": {"entity_type": "cycle", "action_type": "archive", "front_facing_name": "Archive Cycle"}, + "cycles_create": {"entity_type": "cycle", "action_type": "create", "front_facing_name": "Create Cycle"}, + "cycles_remove_work_item": {"entity_type": "cycle", "action_type": "remove", "front_facing_name": "Remove Work Item from Cycle"}, + "cycles_transfer_work_items": {"entity_type": "cycle", "action_type": "transfer", "front_facing_name": "Transfer Work Items to Cycle"}, + "cycles_unarchive": {"entity_type": "cycle", "action_type": "unarchive", "front_facing_name": "Unarchive Cycle"}, + "cycles_update": {"entity_type": "cycle", "action_type": "update", "front_facing_name": "Update Cycle"}, + # Intakes + "intake_create": {"entity_type": "intake", "action_type": "create", "front_facing_name": "Create Intake"}, + "intake_delete": {"entity_type": "intake", "action_type": "delete", "front_facing_name": "Delete Intake"}, + "intake_update": {"entity_type": "intake", "action_type": "update", "front_facing_name": "Update Intake"}, + # Labels + "labels_create": {"entity_type": "label", "action_type": "create", "front_facing_name": "Create Label"}, + "labels_update": {"entity_type": "label", "action_type": "update", "front_facing_name": "Update Label"}, + # Links + "links_create": {"entity_type": "link", "action_type": "create", "front_facing_name": "Create Link"}, + "links_delete": {"entity_type": "link", "action_type": "delete", "front_facing_name": "Delete Link"}, + "links_update": {"entity_type": "link", "action_type": "update", "front_facing_name": "Update Link"}, + # Modules + "modules_add_work_items": {"entity_type": "module", "action_type": "add", "front_facing_name": "Add Work Items to Module"}, + "modules_archive": {"entity_type": "module", "action_type": "archive", "front_facing_name": "Archive Module"}, + "modules_create": {"entity_type": "module", "action_type": "create", "front_facing_name": "Create Module"}, + "modules_remove_work_item": {"entity_type": "module", "action_type": "remove", "front_facing_name": "Remove Work Item from Module"}, + "modules_unarchive": {"entity_type": "module", "action_type": "unarchive", "front_facing_name": "Unarchive Module"}, + "modules_update": {"entity_type": "module", "action_type": "update", "front_facing_name": "Update Module"}, + # Pages + "pages_create_page": {"entity_type": "page", "action_type": "create", "front_facing_name": "Create Page"}, + "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_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"}, + # 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"}, + "properties_create_value": {"entity_type": "property", "action_type": "create_value", "front_facing_name": "Create Property Value"}, + "properties_delete": {"entity_type": "property", "action_type": "delete", "front_facing_name": "Delete Property"}, + "properties_delete_option": {"entity_type": "property", "action_type": "delete_option", "front_facing_name": "Delete Property Option"}, + "properties_update": {"entity_type": "property", "action_type": "update", "front_facing_name": "Update Property"}, + "properties_update_option": {"entity_type": "property", "action_type": "update_option", "front_facing_name": "Update Property Option"}, + # States + "states_create": {"entity_type": "state", "action_type": "create", "front_facing_name": "Create State"}, + "states_update": {"entity_type": "state", "action_type": "update", "front_facing_name": "Update State"}, + # Types + "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"}, + # 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"}, + "workitems_create": {"entity_type": "workitem", "action_type": "create", "front_facing_name": "Create Work Item"}, + "workitems_create_relation": {"entity_type": "workitem", "action_type": "update", "front_facing_name": "Create Work Item Relation"}, + "workitems_update": {"entity_type": "workitem", "action_type": "update", "front_facing_name": "Update Work Item"}, + "workitems_delete": {"entity_type": "workitem", "action_type": "delete", "front_facing_name": "Delete Work Item"}, + # Worklogs + "worklogs_create": {"entity_type": "worklog", "action_type": "create", "front_facing_name": "Create Work Log"}, + "worklogs_delete": {"entity_type": "worklog", "action_type": "delete", "front_facing_name": "Delete Work Log"}, + "worklogs_update": {"entity_type": "worklog", "action_type": "update", "front_facing_name": "Update Work Log"}, +} + def is_retrieval_tool(tool_name: Any) -> bool: """Return True if the tool is a retrieval/lookup tool. @@ -36,29 +118,71 @@ def is_retrieval_tool(tool_name: Any) -> bool: if name.endswith("_list") or name.endswith("_retrieve"): return True # Known retrieval utilities - if name in {"structured_db_tool", "vector_search_tool", "pages_search_tool", "docs_search_tool", "generic_query_tool", "fetch_cycle_details"}: + if name in {"structured_db_tool", "vector_search_tool", "pages_search_tool", "docs_search_tool", "fetch_cycle_details"}: return True return False -"""Tool name mapping utilities.""" +"""Tool name mapping utilities and classification registry.""" + +# ------------------------------ +# Tool classification registry +# ------------------------------ + +# kind: "retrieval" | "action" +# plan_only: True means never execute in planning; store as planned +TOOL_METADATA_REGISTRY: Dict[str, Dict[str, Any]] = { + # Retrieval tools (execute immediately) + "vector_search_tool": {"kind": "retrieval", "plan_only": False}, + "structured_db_tool": {"kind": "retrieval", "plan_only": False}, + "pages_search_tool": {"kind": "retrieval", "plan_only": False}, + "docs_search_tool": {"kind": "retrieval", "plan_only": False}, + "fetch_cycle_details": {"kind": "retrieval", "plan_only": False}, + # Planner/system helpers + "ask_for_clarification": {"kind": "retrieval", "plan_only": False}, + # Common actions (examples - fallback to heuristics for unknown tools) + "workitems_create": {"kind": "action", "plan_only": True}, + "workitems_update": {"kind": "action", "plan_only": True}, + "workitems_create_relation": {"kind": "action", "plan_only": True}, + "modules_add_work_items": {"kind": "action", "plan_only": True}, + "cycles_add_work_items": {"kind": "action", "plan_only": True}, + "create_epic": {"kind": "action", "plan_only": True}, + "projects_retrieve": {"kind": "retrieval", "plan_only": False}, +} + + +def register_tool_metadata(name: str, *, kind: str, plan_only: bool = False) -> None: + """Register or update tool metadata in the classification registry.""" + if not name: + return + TOOL_METADATA_REGISTRY[name] = {"kind": kind, "plan_only": bool(plan_only)} + + +def get_tool_metadata(name: str) -> Dict[str, Any]: + """Return metadata for a tool if available; empty dict if unknown.""" + return TOOL_METADATA_REGISTRY.get(name, {}) + + +def is_plan_only_tool(name: str) -> bool: + """Return True if the tool must not be executed during planning (planned only).""" + return bool(TOOL_METADATA_REGISTRY.get(name, {}).get("plan_only", False)) + import contextlib -from pi.services.schemas.chat import Agents +from pi.services.schemas.chat import RetrievalTools -def tool_name_to_agent(tool_name: str) -> str: - """Convert tool name back to agent name for response formatting.""" - tool_to_agent_map = { - "vector_search_tool": Agents.PLANE_VECTOR_SEARCH_AGENT, - "structured_db_tool": Agents.PLANE_STRUCTURED_DATABASE_AGENT, - "pages_search_tool": Agents.PLANE_PAGES_AGENT, - "docs_search_tool": Agents.PLANE_DOCS_AGENT, - "generic_query_tool": Agents.GENERIC_AGENT, - "action_executor_agent": Agents.PLANE_ACTION_EXECUTOR_AGENT, +def tool_name_to_retrieval_tool(tool_name: str) -> str: + """Convert tool name back to retrieval tool enum value.""" + tool_to_enum_map = { + "vector_search_tool": RetrievalTools.VECTOR_SEARCH_TOOL, + "structured_db_tool": RetrievalTools.STRUCTURED_DB_TOOL, + "pages_search_tool": RetrievalTools.PAGES_SEARCH_TOOL, + "docs_search_tool": RetrievalTools.DOCS_SEARCH_TOOL, + "action_executor_agent": RetrievalTools.ACTION_EXECUTOR_TOOL, } - return tool_to_agent_map.get(tool_name, tool_name) + return tool_to_enum_map.get(tool_name, tool_name) def tool_name_shown_to_user(tool_name: str) -> str: @@ -66,11 +190,11 @@ def tool_name_shown_to_user(tool_name: str) -> str: tool_to_user_map = { "vector_search_tool": "Semantic search", "structured_db_tool": "Database querying", + "search_current_cycle": "Find Current Cycle", "fetch_cycle_details": "Cycle Details", "list_recent_cycles": "Recent Cycles", "pages_search_tool": "Semantic search of pages", "docs_search_tool": "Semantic search of docs", - "generic_query_tool": "General Knowledge", "action_executor_agent": "Action Execution", # Entity search tools "search_project_by_name": "Search Project", @@ -92,20 +216,221 @@ def tool_name_shown_to_user(tool_name: str) -> str: "workitems_list": "List Work-items", "list_member_projects": "List Member Projects", } - return tool_to_user_map.get(tool_name, tool_name) + print("received tool name: ", tool_name) + name_to_return = tool_to_user_map.get(tool_name, "") + if not name_to_return: + name_to_return = TOOL_NAME_TO_CATEGORY_MAP.get(tool_name, {}).get("front_facing_name", tool_name) + print("name to return: ", name_to_return) + return name_to_return -def agent_to_tool_name(agent_name: str) -> str: - """Convert agent name to corresponding tool name.""" - agent_to_tool_map = { - "plane_vector_search_agent": "vector_search_tool", - "plane_structured_database_agent": "structured_db_tool", - "plane_pages_agent": "pages_search_tool", - "plane_docs_agent": "docs_search_tool", - "generic_agent": "generic_query_tool", - "plane_action_executor_agent": "action_executor_agent", +def category_display_name(category: str) -> str: + """ + Convert internal category slug to a user-friendly display name. + Falls back to Title Case of the slug with underscores replaced. + """ + + category = category.strip().lower() + overrides = { + "workitems": "Work items", + "worklogs": "Work logs", + "projects": "Projects", + "cycles": "Cycles", + "modules": "Modules", + "labels": "Labels", + "states": "States", + "pages": "Pages", + "assets": "Assets", + "users": "Users", + "members": "Members", + "activity": "Activity", + "attachments": "Attachments", + "comments": "Comments", + "links": "Links", + "properties": "Properties", + "types": "Types", + "intake": "Intake", } - return agent_to_tool_map.get(agent_name, agent_name) + return overrides.get(category, category.replace("_", " ").title()) + + +def is_uuid_like(value: Any) -> bool: + """Check if a value looks like a UUID.""" + if not isinstance(value, str): + return False + # UUID pattern: 8-4-4-4-12 hex digits with optional hyphens + uuid_pattern = re.compile(r"^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$", re.IGNORECASE) + return bool(uuid_pattern.match(value.strip())) + + +def is_url(value: Any) -> bool: + """Check if a value looks like a URL.""" + if not isinstance(value, str): + return False + return value.startswith(("http://", "https://", "ftp://")) + + +def clean_result_dict(data: Any, depth: int = 0) -> Any: + """ + Recursively clean a result dictionary by removing UUIDs, URLs, and technical fields. + Keeps user-friendly fields like names, identifiers, counts, dates, etc. + + Args: + data: The data structure to clean (dict, list, or primitive) + depth: Current recursion depth (to prevent infinite loops) + + Returns: + Cleaned data structure + """ + if depth > 10: # Safety limit + return "..." + + # Fields to always exclude + EXCLUDE_FIELDS = { + "id", + "project_id", + "workspace_id", + "user_id", + "member_id", + "cycle_id", + "module_id", + "state_id", + "label_id", + "type_id", + "parent_id", + "url", + "entity_url", + "workspace", + "created_by_id", + "updated_by_id", + "owner_id", + "assignee_id", + "reporter_id", + "lead_id", + } + + # Fields to always keep (even if they look like UUIDs) + KEEP_FIELDS = { + "name", + "identifier", + "title", + "description", + "count", + "total", + "status", + "priority", + "state", + "type", + "access", + "color", + "start_date", + "end_date", + "target_date", + "created_at", + "updated_at", + "is_current", + "is_active", + "is_draft", + "is_archived", + } + + if isinstance(data, dict): + cleaned = {} + for key, value in data.items(): + key_lower = key.lower() + + # Skip excluded fields + if key_lower in EXCLUDE_FIELDS: + continue + + # Skip UUID-like values unless it's a keep field + if is_uuid_like(value) and key_lower not in KEEP_FIELDS: + continue + + # Skip URLs + if is_url(value): + continue + + # Recursively clean nested structures + cleaned_value = clean_result_dict(value, depth + 1) + + # Only include non-empty cleaned values + if cleaned_value not in (None, {}, []): + cleaned[key] = cleaned_value + + return cleaned or None + + elif isinstance(data, list): + cleaned_list = [] + for item in data: + cleaned_item = clean_result_dict(item, depth + 1) + if cleaned_item not in (None, {}, []): + cleaned_list.append(cleaned_item) + return cleaned_list or None + + else: + # Primitives: return as-is unless it's a UUID or URL + if is_uuid_like(data) or is_url(data): + return None + return data + + +def format_tool_message_for_display(content: Optional[str | dict]) -> str: + """ + Parse and clean tool message content for user-friendly display. + Removes UUIDs, URLs, and technical fields while preserving meaningful information. + + Args: + content: Raw tool message content string (or dict that will be converted) + + Returns: + Cleaned, user-friendly tool message string + """ + if not content: + return "" + + # Handle case where content is still a dict (shouldn't happen but defensive) + if isinstance(content, dict): + # Extract just the message field if it's a structured response + if "message" in content: + return content["message"] + # Otherwise convert to string + content = str(content) + + try: + # Split into message and result parts + parts = content.split("\n\nResult:", 1) + success_message = parts[0].strip() if parts else "" + result_part = parts[1].strip() if len(parts) > 1 else "" + + if not result_part: + # No result section, just return the message as-is + return content + + # We have a Result section - hide it and return just the message + # The LLM gets the full data, user only sees the clean message + return success_message or "✅ Operation completed successfully" + + except Exception as e: + # On any error, return original for debugging purposes but log it + log.debug(f"Error formatting tool message: {e}") + return content + + +def retrieval_tool_to_tool_name(retrieval_tool: str) -> str: + """Convert retrieval tool enum value to corresponding LangChain tool name.""" + enum_to_tool_map = { + "vector_search_tool": "vector_search_tool", + "structured_db_tool": "structured_db_tool", + "pages_search_tool": "pages_search_tool", + "docs_search_tool": "docs_search_tool", + "action_executor_tool": "action_executor_agent", + } + return enum_to_tool_map.get(retrieval_tool, retrieval_tool) + + +# Legacy function name for backward compatibility +agent_to_tool_name = retrieval_tool_to_tool_name def log_toolset_details(tools: List[Any], chat_id: str) -> None: @@ -182,10 +507,37 @@ def log_toolset_details(tools: List[Any], chat_id: str) -> None: # Action Executor helper methods # ------------------------------ +# TOOL_CALL_REASONING_INSTRUCTIONS = """**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""" # noqa: E501 + + +# TOOL_CALL_REASONING_REINFORCEMENT = """**FINAL 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...") # noqa: E501 +# - AFTER: Summarize what you found and your next step (e.g., "Found Anil Kumar (ID: xyz-123). Now searching for workitems assigned to them...") # noqa: E501 +# - 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_calls""" # noqa: E501 + # Build the planning method prompt used by the executor def build_method_prompt( - combined_agent_query: str, + combined_tool_query: str, project_id: Optional[str], user_id: Optional[str], workspace_id: Optional[str], @@ -227,17 +579,41 @@ Use retrieval tools to gather information, then plan the modifying actions based - Do not repeat the same action multiple times - Do not try to execute actions - only plan them -**CRITICAL WORKFLOW:** -1. **FIRST**: Use retrieval tools to gather required information (IDs, existence checks, etc.) -2. **THEN**: Plan at least one MODIFYING ACTION that will change data +**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 +| - INSTEAD: Use ONE `structured_db_tool` call WITHOUT `project_id` parameter +| - Query will automatically scope to workspace via `workspace_id` (already in context) +| - Example WRONG: `list_member_projects` + 7x `structured_db_tool` (each with different project_id) +| - Example RIGHT: Single `structured_db_tool`: "list all workitems prioritized as high" +|- For project-specific queries, include the specific `project_id` -**REASONING AND COMMUNICATION:** -- When making tool calls, ALWAYS provide a brief explanation of your reasoning in your response -- Explain why you're selecting specific tools and what you're trying to accomplish -- This helps with debugging and understanding your decision-making process -- Example: "I need to create a cycle as per the user's request, so I'll use cycles_create with the 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_*, 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`) + - If the feature is NOT enabled: Plan BOTH `projects_update` (to enable the feature) AND the creation action (e.g., `cycles_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) 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), 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. +- 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 -**RETRIEVAL RESULT RELEVANCE (CRITICAL):** +**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. + - 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. + + +**RETRIEVAL RESULT RELEVANCE IN PLANNING ACTIONS FOR MODIFYING REQUESTS (CRITICAL):** - Treat retrieval results as candidates, not ground truth. - Use results ONLY if they directly match the user's current intent and entities; otherwise ignore them. - Never copy retrieval text verbatim into parameters like description_html unless the user explicitly asked for it. @@ -270,26 +646,78 @@ Use retrieval tools to gather information, then plan the modifying actions based - **No identical retries**: Do not call the same retrieval tool with the exact same parameters more than once. If it returns no/invalid results, proceed to the next fallback (within the same entity type) or ask for clarification. - **Do not loop the same call.** -**MANDATORY PROJECT/ENTITY RESOLUTION (NO PLACEHOLDERS FOR EXISTING ENTITIES):** -- If the user mentions an EXISTING entity by NAME or IDENTIFIER, you MUST resolve it FIRST using the appropriate search tool and then use its UUID: - - project NAME → `search_project_by_name` FIRST - - project IDENTIFIER (e.g., 'HYDR', 'PARM') → `search_project_by_identifier` FIRST - - cycle/module/label/state/user/workitem NAME/IDENTIFIER → use the corresponding `search_*_by_name` / `search_*_by_identifier` tool FIRST -- Extract the UUID from the search response before using it in any subsequent tools. -- NEVER use names/identifiers directly as *_id parameters. -- NEVER emit placeholders for EXISTING entities named by the user (e.g., `project_id: ""` is forbidden). Resolve to a UUID instead. -- **CRITICAL**: If you need to list modules/cycles but don't have project_id, call `projects_list` FIRST to get options, then ask user which project via `ask_for_clarification` with the projects as disambiguation_options +**CRITICAL: ENTITY ID RESOLUTION AND PLACEHOLDER RULES** + +**RULE 1: EXISTING ENTITIES - USE ACTUAL UUIDs (NO PLACEHOLDERS)** +When the user mentions an EXISTING entity (one that already exists in Plane): +- **YOU MUST**: Call the appropriate search tool FIRST to get its UUID + - project NAME → `search_project_by_name` → extract UUID from response + - project IDENTIFIER (e.g., 'HYDR', 'PARM') → `search_project_by_identifier` → extract UUID + - cycle/module/label/state/user/workitem NAME/IDENTIFIER → `search_*_by_name` / `search_*_by_identifier` → extract UUID + - **EXCEPTION**: User pronouns ('me', 'my', 'I', 'mine') → use User ID from USER CONTEXT directly +- **YOU MUST**: Extract the actual UUID from the search tool response (usually in the `id` field) +- **YOU MUST**: Use that extracted UUID directly in ALL subsequent tool calls +- **FORBIDDEN**: Using placeholders like `` for existing entities +- **FORBIDDEN**: Using names/identifiers directly as *_id parameters (e.g., `project_id: "Mobile"`) + +**RULE 2: NEWLY CREATED ENTITIES - USE PLACEHOLDERS** +When planning actions that depend on entities you CREATE in the CURRENT PLAN: +- **YOU MAY**: Use placeholder references for newly created entities +- **PLACEHOLDER FORMAT**: `` + - Module created in this plan: `` + - Workitem created in this plan: `` + - Cycle created in this plan: `` + - Project created in this plan: `` +- **EXECUTION**: The system will resolve these placeholders during execution after the entities are created + +**CRITICAL EXAMPLES - MIXED EXISTING AND NEW ENTITIES:** + +Example 1: "Add workitems 'Login Page' and 'Logout Page' to new cycle 'Sprint 24'" +- Step 1: search_workitem_by_name("Login Page") → Response: + {{ + "id": "abc-123-uuid", + "name": "Login Page" + }} + → Extract UUID: abc-123-uuid +- Step 2: search_workitem_by_name("Logout Page") → Response: + {{ + "id": "def-456-uuid", + "name": "Logout Page" + }} + → Extract UUID: def-456-uuid +- Step 3: Plan cycles_create(name="Sprint 24") → New cycle, will use placeholder +- Step 4: Plan cycles_add_work_items: + - cycle_id: '' ✅ CORRECT (newly created - use placeholder) + - issues: ['abc-123-uuid', 'def-456-uuid'] ✅ CORRECT (existing - use actual UUIDs from search) + - ❌ WRONG: issues: ['', ''] (these are existing!) + +Example 2: "Create workitem 'Fix login bug' and add it to existing module 'Backend'" +- Step 1: search_module_by_name("Backend") → Extract UUID: "module-xyz-uuid" +- Step 2: Plan workitems_create(name="Fix login bug") → New workitem, will use placeholder +- Step 3: Plan modules_add_work_items: + - module_id: 'module-xyz-uuid' ✅ CORRECT (existing - use actual UUID from search) + - issues: [''] ✅ CORRECT (newly created - use placeholder) + - ❌ WRONG: module_id: '' (this module already exists!) + +**PROJECT-SCOPED ENTITIES - ADDITIONAL REQUIREMENT:** +After resolving project_id for cycles/modules/pages creation: +- **YOU MUST**: Call `projects_retrieve(project_id=...)` to verify the feature is enabled +- **EXCEPTION**: If the project itself is being CREATED in this plan, skip `projects_retrieve` (no UUID yet) + +**ID VALIDATION FOR RETRIEVAL TOOLS (STRICT):** +- NEVER pass placeholders like `` to `*_retrieve`, `*_list`, or `*_get` tools +- ONLY pass real UUIDs to retrieval tools +- Do NOT retrieve entities that are only PLANNED (wait until after execution when UUID exists) +- If you have a name: search first → extract UUID → then retrieve +- If you have a placeholder: do NOT call retrieval until it's resolved to a UUID + **WORKSPACE-LEVEL CONTEXT - USE PROJECT FROM HISTORY:** - In workspace-level chats (no explicit project pre-selected), if the conversation history clearly shows a specific project selection or creation (e.g., an executed action with "Entity: ()" or a project URL containing the UUID), you MUST include that exact UUID as `project_id` for all project-scoped tools (e.g., `workitems_create`, `workitems_update`, `modules_*`, `cycles_*`). - Prefer the most recent project in the history when multiple appear; if multiple conflicting projects are present, disambiguate by selecting the one explicitly referenced in the current user request; otherwise ask for clarification. - Only omit `project_id` when no project is inferable from the history or current query. -**TOOL TYPES:** -- **Retrieval tools** (search_*, *_list, *_retrieve): Execute immediately, gather info -- **Modifying actions** (*_create, *_update, *_add, *_remove): Planned for user approval - -**IMPORTANT**: Only plan modifying actions if the user's request actually requires modifying data. If the request cannot be fulfilled with available tools (e.g., analytics, visualizations, external integrations), return NO_ACTIONS_PLANNED instead of creating workaround entities. +**IMPORTANT**: Only plan modifying actions if the user's request actually requires modifying data. If the request cannot be fulfilled with available tools (e.g., analytics, visualizations, external integrations), provide a polite explanation instead of creating workaround entities. **INTERLINKED ACTIONS GUIDANCE:** - **Multi-step operations**: When a request involves multiple related actions, you MUST plan ALL of them @@ -305,7 +733,7 @@ Use retrieval tools to gather information, then plan the modifying actions based **WORK-ITEM CREATION CAPABILITIES:** **✅ CAN be set during workitems_create:** - name, description, priority, state, assignees, labels, story_points, start_date, target_date -- **EPIC CREATION**: Use `epics_create` tool to create epics - this automatically sets the correct epic type_id +- **EPIC CREATION**: Use `create_epic` tool to create epics - this automatically sets the correct epic type_id - **IMPORTANT**: Use workitems_create with ALL properties at once - do NOT create then update! **WORK-ITEM RELATIONS CAPABILITIES:** @@ -322,121 +750,56 @@ Use retrieval tools to gather information, then plan the modifying actions based **EFFICIENCY RULE**: Always try to set as many properties as possible during creation to minimize API calls. -**SPECIFIC EXAMPLES**: -* "Make issue A block issue B" = search_workitem_by_name for A, search_workitem_by_name for B, then workitems_create_relation(issue_id=A_id, relation_type="blocking", related_issues=[B_id]) -* "Mark task X as duplicate of task Y" = search for X and Y to get IDs, then workitems_create_relation(issue_id=X_id, relation_type="duplicate", related_issues=[Y_id]) -* "Issue C should start after issues D and E finish" = search for C, D, E to get IDs, then workitems_create_relation(issue_id=C_id, relation_type="start_after", related_issues=[D_id, E_id]) -* "Move work item to module" = search for existing work item, then modules_add_work_items -* "Create work item in module" = workitems_create, then modules_add_work_items -* "Create work item with priority and state" = workitems_create with BOTH priority AND state (single call) -* "Create work item and add to module" = workitems_create, then modules_add_work_items (two calls needed) -* "Create issue domino with Low priority and Backlog state" = workitems_create with name='domino', priority='low', state_id='backlog-uuid' (single call) -* "Move work item to cycle" = search for existing work item, then cycles_add_work_items -* Creating entities AND adding them to containers = plan BOTH create + add actions -**PLANNING DEPENDENT ACTIONS (PLACEHOLDERS ONLY FOR NEWLY CREATED ENTITIES):** -- When planning multiple actions that depend on an entity you CREATE in the same plan, you may use a descriptive reference for that newly created entity in downstream actions. -- For containers (cycles, modules, projects) or entities (work items, labels, states) that ALREADY EXIST, DO NOT use placeholders; resolve to UUIDs via search tools. -- **PLACEHOLDER SYSTEM (new entities only)**: - - Module created in this plan 'my-module' → downstream action may refer to `` - - Workitem created in this plan 'bug fix' → downstream action may refer to `` - - Project created in this plan 'my-project' → downstream action may refer to `` - - The execution phase will resolve these placeholders using retrieval tools ONLY for entities that were created in the current plan. - -**OPTIMIZATION GUIDANCE:** -- **Descriptive references**: Use entity names/descriptions that clearly identify what should be linked -- **Eliminate redundancy**: Don't plan separate actions for each item if they can be handled in a single action if the target tool accepts a list of items - -**PLACEHOLDER PLANNING EXAMPLE:** -When planning: "Create workitem 'bug fix' and add it to module 'my-module'" -- Plan: `workitems_create` with `name: 'bug fix'` -- Plan: `modules_add_work_items` with `module_id: 'my-module'` and `issues: ['bug fix']` -- The system will automatically convert 'my-module' → `` and 'bug fix' → `` -- During execution, the LLM will resolve these placeholders using retrieval tools **IMPORTANT**: Analyze the user's request carefully to identify ALL required actions, not just the obvious ones. {RETRIEVAL_TOOL_DESCRIPTIONS} **Execution Guidelines:** -- **Step 1**: Use retrieval tools (search_*_by_name, list_member_projects, structured_db_tool, etc.) to gather IDs and verify entities exist -- **Step 2**: Plan modifying actions using the gathered information -- **PROJECT LISTING**: When you need a list of active projects for scope selection or disambiguation, **PREFER** `list_member_projects` over `structured_db_tool` or `projects_list` to avoid archived/deleted projects -- **CRITICAL - USE UUIDs, NOT NAMES**: When calling tools that expect IDs (like project_id, module_id, etc.), always use the UUID from the resolved entity, NOT the entity name -- **STEP-BY-STEP PROCESS**: - 1. Call search tool (e.g., `search_project_by_name`) - 2. Extract the "id" field from the JSON response - 3. Use that exact UUID string in subsequent tool calls -- **WRONG**: Using entity names like "MyProject", "ExampleModule", etc. for ID parameters -- **CORRECT**: Using the actual UUID string returned by search tools -- **For Relation Tasks**: - - **Step 1**: ALWAYS search for ALL work items mentioned by name or identifier to get their UUIDs - - **Step 2**: Use workitems_create_relation with the collected UUIDs - - **CRITICAL**: Never attempt relations without first collecting actual work item IDs -- Do NOT use vector_search_tool for finding projects, users, or other non-content queries -- Plan actions in logical dependency order (e.g., create issue first, then add to container) -- **Optimize similar actions**: If adding multiple items to the same target, use ONE action with a list -- **CRITICAL**: Do NOT provide workspace_slug parameters - they will be automatically provided from context -- Do not use 'search_workitem_by_identifier' if indentifier you are passing as param to it doesn't follow the format , ex: 'ABCD-23' -- Brief summary: - - If you planned actions, provide a very very brief summary of the actions you planned. - - If part of the user's intent cannot be fulfilled due to API/tool limitations, provide a ONE-LINE explanatory note ONLY when strictly necessary. - - If you are unable to plan ANY modifying action after retrieval, respond with EXACTLY: NO_ACTIONS_PLANNED - - Never ask the user to confirm in text; the system UI will request confirmation. +- Use search tools for entity resolution, NOT vector_search_tool +- Do NOT provide workspace_slug - auto-provided from context +- search_workitem_by_identifier requires format: PROJECT-123 -**EXAMPLE WORKFLOW FOR "MOVE" OPERATIONS:** -For "move workitem X to module Y": -1. Call `search_project_by_name` → get project UUID (executes immediately) -2. Call `search_workitem_by_name` → get work item UUID (executes immediately) -3. Call `search_module_by_name` → get module UUID (executes immediately) -4. Plan `modules_add_work_items` with the gathered UUIDs (requires user approval) -5. Use the project UUID for subsequent calls: `search_workitem_by_name` with the UUID from step 1 -6. Use the project UUID for subsequent calls: `search_module_by_name` with the UUID from step 1 -**MANDATORY WORKFLOW - NO EXCEPTIONS:** -1. **FIRST**: You MUST call `search_project_by_name("project-name")` to get the project UUID -2. **THEN**: Extract the UUID from the "id" field in the response -3. **FINALLY**: Use that exact UUID string in ALL subsequent tool calls -4. **FORBIDDEN**: Using project names like "Mobile", "MyProject" etc. for project_id parameters -**EXAMPLE SEQUENCE:** -- User says: "create issue in Morpheus project" -- Step 1: Call `search_project_by_name("Morpheus")` → get UUID -- Step 2: Use UUID in `workitems_create(project_id="actual-uuid-from-step-1")` -- NEVER: `workitems_create(project_id="Morpheus")` ← This will fail! +**WORKFLOW DECISION TREE:** -**CRITICAL**: "Move" means add existing entity to container - do NOT create new entities! +**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 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 +4. **Formatting Requirements**: + - Use "work-item" (not "issue") and "unique key" (not "Issue ID") terminology + - Suppress UUIDs - they are PII (exception: unique keys like PAI-123 are not UUIDs, can show) + - No hallucination - if no data, say so clearly without mentioning SQL/tools/internals + - Never reveal sensitive info: passwords, API keys, table names, SQL queries + - Create clickable URLs: `[PAI-123](url)` for work-items, `[name](url)` for others + - Use tables for multi-attribute data (suppress UUIDs, apply URL rules) +5. This formatting doesn't apply to modification requests - those require action planning. -**STRICT OUTPUT RULES (Very Brief Summary / NO CONFIRMATION TEXT):** -- Do NOT provide meta commentary. Do NOT say "I will plan..." or "I will update...". -- Do NOT ask the user to click 'Confirm' or otherwise prompt for approval in text. The system handles approval. -- When actions are planned: provide a very brief summary of the actions you planned. -- When no actions can be planned: use the `no_actions_planned` tool with a clear reason and optional explanation. - -**STOP CONDITION:** +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. **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 (create, update, delete, add, remove, move, etc.) -3. You CANNOT stop after just searching/retrieving - you MUST plan the modifying actions +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 +4. Provide a very brief summary of what you planned in your content -**IF the user's request is asking for unsupported features:** -- Analytics/visualizations (pie charts, reports, dashboards, graphs) -- External integrations (Slack, GitHub, Jira sync) -- Bulk operations (bulk delete, mass archive) -- Administrative functions (workspace settings, permissions, billing) -- File uploads/management -- Use the `no_actions_planned` tool with reason explaining why the feature is unsupported + +**IF the user's request cannot be fulfilled with available tools:** +- Examples: Analytics/visualizations, external integrations, bulk operations, administrative functions, file uploads +- Use retrieval tools if relevant to understand the request +- Then provide a polite, brief explanation of why it cannot be done and what alternatives exist (if any) - Do NOT create workaround entities (like workitems) to satisfy these requests - -**CRITICAL**: Distinguish between legitimate data modification requests and requests for unsupported features. Only enforce the "must plan action" requirement for the former. +- Do NOT plan any actions - just provide the explanation in your content """ # noqa: E501 if project_id: method_prompt += f"\n\n**🔥 PROJECT CONTEXT (CRITICAL):**\nProject ID: {project_id}\n\n**IMPORTANT SCOPING RULES:**\n- This is a PROJECT-LEVEL chat - ALL operations are scoped to THIS PROJECT ONLY\n- When the request mentions 'current cycle', 'current module', 'work items', etc. - it means ONLY within THIS PROJECT\n- Use this project_id for ALL tools that accept project_id parameter\n- DO NOT query across all projects - scope everything to THIS specific project\n- User refers to 'this project'/'the project'/'current project' = use this project_id" # noqa: E501 else: # Workspace-level context (no specific project) - method_prompt += f"\n\n**🌐 WORKSPACE CONTEXT (CRITICAL):**\nWorkspace ID: {workspace_id}\n\n**IMPORTANT SCOPING RULES:**\n- This is a WORKSPACE-LEVEL chat - queries can span MULTIPLE PROJECTS\n- When the request mentions 'last cycle', 'this cycle', 'work items', etc. WITHOUT specifying a project - it could be in ANY project\n- Use list_member_projects (without limit or with high limit) to get ALL projects in the workspace\n- Then iterate through projects to find relevant cycles/modules/work-items\n- CRITICAL: Do NOT limit to 1 project unless the user specifically names or refers to a specific project" # noqa: E501 + method_prompt += f"\n\n**🌐 WORKSPACE CONTEXT (CRITICAL):**\nWorkspace ID: {workspace_id}\n\n**IMPORTANT SCOPING RULES:**\n- This is a WORKSPACE-LEVEL chat - queries can span MULTIPLE PROJECTS\n- When the request mentions 'last cycle', 'this cycle', 'work items', etc. WITHOUT specifying a project - it could be in ANY project\n- Use list_member_projects (with a high limit) to get ALL projects in the workspace\n- Iterate through projects ONLY for entities that are inherently project-scoped (cycles/modules/states/labels). For workspace-wide work-item queries, use a SINGLE structured_db_tool call WITHOUT project_id (it will scope via workspace_id)\n- CRITICAL: Do NOT limit to 1 project unless the user specifically names or refers to a specific project" # noqa: E501 if user_id: method_prompt += f"\n\n**USER CONTEXT:**\nUser ID: {user_id}\nUse this when user refers to him/herself or 'I' or 'me' or 'my' or 'mine' or any other personal pronoun or any derivative of these words." # noqa: E501 @@ -444,6 +807,8 @@ For "move workitem X to module Y": if enhanced_conversation_history and enhanced_conversation_history.strip(): method_prompt += f"\n\n**CONVERSATION HISTORY & ACTION CONTEXT:**\n{enhanced_conversation_history}\n\nBased on this conversation history, you can reference previously created entities, their IDs, and project context without needing to search for them again." # noqa: E501 + log.info(f"ENHANCED_CONVERSATION_HISTORY being sent to LLM:\n{enhanced_conversation_history}") + # Inject clarification context if present (from previous turn's ask_for_clarification) if clarification_context and isinstance(clarification_context, dict): try: @@ -492,8 +857,72 @@ For "move workitem X to module Y": return method_prompt +# ------------------------------ +# Clarification context builders +# ------------------------------ + + +def build_clarification_context_block(clar_ctx: dict | None) -> str: + """Builds a formatted clarification context block for prompts. + + Expected clar_ctx keys: original_query, reason, disambiguation_options (list of dicts), answer_text + """ + try: + if not clar_ctx or not isinstance(clar_ctx, dict): + return "" + + original_query_text = clar_ctx.get("original_query") + reason = clar_ctx.get("reason") + disambig_options = clar_ctx.get("disambiguation_options") or [] + answer_text = clar_ctx.get("answer_text") + + parts: list[str] = [] + parts.append("\n\n**CLARIFICATION CONTEXT:**\n") + if original_query_text: + parts.append(f"Original user request: {original_query_text}\n") + if reason: + parts.append(f"Clarification reason: {reason}\n") + if disambig_options: + parts.append("The user was previously shown these options:\n") + for idx, opt in enumerate(disambig_options, 1): + if isinstance(opt, dict): + opt_id = opt.get("id") + opt_name = opt.get("name") or opt.get("display_name") or "" + opt_identifier = opt.get("identifier") or "" + opt_email = opt.get("email") or "" + + if opt_email: + parts.append(f" {idx}. {opt_name} ({opt_email}) → UUID: {opt_id}\n") + elif opt_identifier: + parts.append(f" {idx}. {opt_name} (Identifier: {opt_identifier}) → UUID: {opt_id}\n") + else: + parts.append(f" {idx}. {opt_name} → UUID: {opt_id}\n") + if answer_text: + parts.append(f"\nUser's clarification answer: {answer_text}\n") + parts.append( + "\nIMPORTANT: The current user message is a clarification response to the original request above. " + "Use the clarification answer to resolve missing information and continue with the ORIGINAL request, " + "not as a new standalone request.\n" + ) + + return "".join(parts) + except Exception: + return "" + + def classify_tool(tool_name: str) -> Tuple[bool, bool]: - """Return (is_retrieval_tool, is_action_tool) based on name heuristics.""" + """Return (is_retrieval_tool, is_action_tool) using registry first, then heuristics.""" + # 1) Registry check (authoritative if present) + meta = get_tool_metadata(tool_name) + if meta: + kind = str(meta.get("kind") or "").lower() + if kind == "retrieval": + return True, False + if kind == "action": + return False, True + # Fall through to heuristics if malformed + + # 2) Heuristics fallback # Include both prefix and substring patterns for robustness read_only_patterns = [ "list_", # prefix form like list_member_projects @@ -781,7 +1210,8 @@ def format_clarification_as_text(clarification_data: Dict[str, Any]) -> str: REQUIRED_FIELDS_BY_TOOL: Dict[str, List[str]] = { # Workitems "workitems_create": ["project_id", "name"], - "workitems_update": ["issue_id", "project_id"], + # For updates, issue_id is sufficient at planning time; project_id is auto-resolved from issue_id during execution + "workitems_update": ["issue_id"], # Modules "modules_create": ["name", "project_id"], "modules_add_work_items": ["module_id", "issues", "project_id"], @@ -829,6 +1259,11 @@ def preflight_missing_required_fields(tool_name: str, tool_args: Dict[str, Any], return missing args = tool_args or {} + # Determine if this is an action tool; placeholders are allowed during planning for action tools + try: + _is_retrieval, _is_action = classify_tool(tool_name) + except Exception: + _is_action = False # UUID format for strict validation of *_id fields uuid_pattern = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE) for key in required: @@ -844,8 +1279,11 @@ def preflight_missing_required_fields(tool_name: str, tool_args: Dict[str, Any], if isinstance(val, str) and key == "project_id" and val == "__workspace_scope__": is_missing = False elif isinstance(val, str): - # Missing if placeholder or non-UUID - if "" are allowed for ACTION tools during planning + if " Optional[Dict[str, Any]]: """Handle missing required fields by creating clarification payload with disambiguation options. @@ -1231,7 +1669,7 @@ async def handle_missing_required_fields( "clarification_pending": True, "clarification_payload": clarification_payload, # CRITICAL: Store the original query so we can reconstruct full context on clarification follow-up - "original_query": combined_agent_query, + "original_query": combined_tool_query, }, "is_planned": False, "is_executed": False, @@ -1247,3 +1685,42 @@ async def handle_missing_required_fields( except Exception: return None + + +async def stream_content_in_chunks(content: str, words_per_chunk: int = 15, delay_seconds: float = 0.08) -> AsyncIterator[str]: + """ + Stream content in chunks of words with a slight delay to simulate streaming. + + Args: + content: The full content to stream + words_per_chunk: Number of words to include in each chunk (default: 15) + delay_seconds: Delay between chunks in seconds (default: 0.08) + + Yields: + Content chunks as strings + """ + if not content: + return + + # Split by whitespace but keep delimiters to preserve formatting (newlines, tabs, etc.) + # This ensures tables and other formatted text are preserved exactly. + tokens = re.split(r"(\s+)", content) + + current_chunk = [] + word_count = 0 + + for token in tokens: + current_chunk.append(token) + # Count non-whitespace tokens as words + if token and not token.isspace(): + word_count += 1 + + if word_count >= words_per_chunk: + yield "".join(current_chunk) + current_chunk = [] + word_count = 0 + await asyncio.sleep(delay_seconds) + + # Yield remaining content + if current_chunk: + yield "".join(current_chunk) diff --git a/apps/pi/pi/services/chat/kit.py b/apps/pi/pi/services/chat/kit.py index 361f772f82..17a5c22200 100644 --- a/apps/pi/pi/services/chat/kit.py +++ b/apps/pi/pi/services/chat/kit.py @@ -5,7 +5,6 @@ from typing import Dict from typing import List from typing import Optional -from langchain_core.messages import BaseMessage from langchain_core.prompts import ChatPromptTemplate from langchain_core.tools import tool from pydantic import UUID4 @@ -13,7 +12,6 @@ from sqlmodel.ext.asyncio.session import AsyncSession from pi import logger from pi import settings -from pi.agents.sql_agent import text2sql from pi.agents.sql_agent.tools import construct_entity_urls_vectordb from pi.agents.sql_agent.tools import extract_ids_from_sql_result from pi.agents.sql_agent.tools import format_as_bullet_points @@ -31,14 +29,12 @@ from pi.services.retrievers import pg_store from pi.services.retrievers.docs_search import DocsRetriever from pi.services.retrievers.issue_search import IssueRetriever from pi.services.retrievers.pages_search import PageChunkRetriever -from pi.services.schemas.chat import Agents from pi.services.schemas.chat import QueryFlowStore +from .helpers.build_mode_helpers import build_method_executor_and_context from .mixins import AttachmentMixin from .prompts import combination_system_prompt from .prompts import combination_user_prompt -from .prompts import generic_prompt as GENERIC_PROMPT -from .prompts import generic_prompt_non_plane from .prompts import title_generation_prompt from .utils import StandardAgentResponse from .utils import format_message_with_attachments @@ -64,21 +60,22 @@ class ChatKit(AttachmentMixin): 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 - model_name_mapping = { + claude_model_name_mapping = { "claude-sonnet-4": settings.llm_model.LITE_LLM_CLAUDE_SONNET_4, "claude-sonnet-4-0": settings.llm_model.CLAUDE_SONNET_4_0, "claude-sonnet-4-5": settings.llm_model.CLAUDE_SONNET_4_5, } + tool_llm_streaming = False if not switch_llm: switch_llm = settings.llm_model.GPT_4_1 TOOL_LLM = settings.llm_model.GPT_4_1 - tool_config = LLMConfig(model=TOOL_LLM, temperature=0.2, streaming=False) + tool_config = LLMConfig(model=TOOL_LLM, temperature=0.2, streaming=tool_llm_streaming) else: # Map user-friendly model names to actual model names - actual_model_name = model_name_mapping.get(switch_llm, switch_llm) + actual_model_name = claude_model_name_mapping.get(switch_llm, switch_llm) - if switch_llm in model_name_mapping: + if switch_llm in claude_model_name_mapping: # This is a Claude model if actual_model_name in [settings.llm_model.CLAUDE_SONNET_4_0, settings.llm_model.CLAUDE_SONNET_4_5]: # Direct Anthropic API models @@ -90,7 +87,7 @@ class ChatKit(AttachmentMixin): tool_config = LLMConfig( model=TOOL_LLM, temperature=0.2, - streaming=False, + streaming=tool_llm_streaming, base_url=settings.llm_config.LITE_LLM_HOST, api_key=settings.llm_config.LITE_LLM_API_KEY, ) @@ -102,7 +99,7 @@ class ChatKit(AttachmentMixin): TOOL_LLM = "gpt-5" # Use base GPT-5 model name for OpenAI API tool_config = LLMConfig( model=TOOL_LLM, - streaming=False, + streaming=tool_llm_streaming, reasoning_effort="medium", use_responses_api=settings.llm_config.GPT5_USE_RESPONSES_API, # Configurable via env var ) @@ -111,7 +108,7 @@ class ChatKit(AttachmentMixin): TOOL_LLM = "gpt-5" # Use base GPT-5 model name for OpenAI API tool_config = LLMConfig( model=TOOL_LLM, - streaming=False, + streaming=tool_llm_streaming, reasoning_effort="low", use_responses_api=settings.llm_config.GPT5_USE_RESPONSES_API, # Configurable via env var ) @@ -125,7 +122,7 @@ class ChatKit(AttachmentMixin): else: # This is a regular OpenAI model TOOL_LLM = switch_llm - tool_config = LLMConfig(model=TOOL_LLM, temperature=0.2, streaming=False) + 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: @@ -163,15 +160,13 @@ class ChatKit(AttachmentMixin): self.chat_llm = llms.get_chat_llm(switch_llm) # Log key model info - chat_model = getattr(self.chat_llm, "model_name", "unknown") - reasoning_effort = "N/A" + getattr(self.chat_llm, "model_name", "unknown") if switch_llm == "gpt-5-standard": - reasoning_effort = "medium" + pass elif switch_llm == "gpt-5-fast": - reasoning_effort = "low" - log.info(f"Using model: {chat_model} (reasoning_effort: {reasoning_effort}), switch_llm: {switch_llm}") + pass - self.tool_llm = create_openai_llm(tool_config) + self.tool_llm = llms.LazyLLM(lambda: create_openai_llm(tool_config)) self.issue_retriever = IssueRetriever( num_docs=settings.chat.NUM_SIMILAR_DOCS, chunk_similarity_threshold=settings.vector_db.ISSUE_VECTOR_SEARCH_CUTOFF ) @@ -190,19 +185,19 @@ class ChatKit(AttachmentMixin): self.vector_search_issue_ids: list[str] = [] self.vector_search_page_ids: list[str] = [] self.current_context: dict[str, Any] = {} - # Store standardized agent responses for URL access - self.agent_responses: dict[str, Dict[str, Any]] = {} + # Store standardized tool responses for URL access + self.tool_responses: dict[str, Dict[str, Any]] = {} # Token tracking context (set externally when needed) self._token_tracking_context: Optional[Dict[str, Any]] = None def _store_agent_response(self, tool_name: str, response: Dict[str, Any]) -> None: - """Store standardized agent response for later URL access""" - self.agent_responses[tool_name] = response + """Store standardized tool response for later URL access""" + self.tool_responses[tool_name] = response def _get_stored_response(self, tool_name: str) -> Optional[Dict[str, Any]]: - """Get stored agent response""" - return self.agent_responses.get(tool_name) + """Get stored tool response""" + return self.tool_responses.get(tool_name) def set_token_tracking_context(self, message_id: UUID4, db: AsyncSession, chat_id: Optional[str] = None) -> None: """Set token tracking context for this chat session""" @@ -365,13 +360,13 @@ Provide concise, relevant context from the attachment(s):""" entity_urls = None try: extracted_ids = extract_ids_from_sql_result(query_execution_result) - + log.info(f"ChatID: {chat_id} - _create_entity_urls_for_db_search: Extracted IDs: {extracted_ids}") # Construct URLs using dynamic function (no need for user_meta) if any(extracted_ids.values()): api_base_url = settings.plane_api.FRONTEND_URL - + log.info(f"ChatID: {chat_id} - kit.py:_create_entity_urls_for_db_search: API Base URL: {api_base_url}") entity_urls = await construct_entity_urls_vectordb(entity_ids=extracted_ids, api_base_url=api_base_url) - + log.info(f"ChatID: {chat_id} - kit.py:_create_entity_urls_for_db_search: Entity URLs: {entity_urls}") # intermediate_results["entity_urls"] = entity_urls query_flow_store["tool_response"] += f"Entity extraction: {sum(len(ids) for ids in extracted_ids.values())} entities found\n" @@ -645,35 +640,6 @@ Provide concise, relevant context from the attachment(s):""" # The action executor intercepts this tool call and streams a dedicated clarification event. return json.dumps(payload) - @tool - async def no_actions_planned( - reason: str, - explanation: Optional[str] = None, - ) -> str: - """Use this when you determine that no actions need to be planned for the user's request. - - This should be used when: - - The request is asking for information only (no data modification needed) - - The request is for unsupported features (analytics, external integrations, etc.) - - The request cannot be fulfilled with available tools - - All necessary information has been retrieved and no further actions are needed - - Args: - reason: Short description of why no actions are needed (e.g., "Request is for information only"). - explanation: Optional detailed explanation for the user about why no actions were planned. - - Returns: - JSON string with the reasoning for downstream handling. - """ - payload: Dict[str, Any] = { - "reason": reason, - "explanation": explanation or "", - "token": "NO_ACTIONS_PLANNED", - } - - # The action executor will intercept this and handle the no-actions flow - return json.dumps(payload) - @tool async def vector_search_tool(query: str) -> str: """Search for issues using semantic vector search. Use this first when you need to find issues related to specific topics or keywords. @@ -729,7 +695,10 @@ Provide concise, relevant context from the attachment(s):""" self._store_agent_response("structured_db_tool", response) # Extract and return the results text - return StandardAgentResponse.extract_results(response) + # result = StandardAgentResponse.extract_results(response) + result = StandardAgentResponse.format_response_with_entity_urls(response) + log.info(f"ChatID: {chat_id} - kit.py:structured_db_tool: format_response_with_entity_urls result: {result}") + return result except Exception as e: log.error(f"Error in structured_db_tool: {str(e)}") return f"Error querying database: {str(e)}" @@ -1135,32 +1104,21 @@ Provide concise, relevant context from the attachment(s):""" log.error(f"Error in docs_search_tool: {str(e)}") return f"Error searching documentation: {str(e)}" - @tool - async def generic_query_tool(query: str) -> str: - """Handle general questions that don't require specific data retrieval from the workspace.""" - try: - # Get attachment blocks from the shared context - attachment_blocks = self.get_current_attachment_blocks() - result = await self.handle_generic_query(query, user_id, conversation_history, attachment_blocks=attachment_blocks) - - # Store the standardized response for consistency - self._store_agent_response("generic_query_tool", result) - - # Extract and return the results text - return StandardAgentResponse.extract_results(result) - except Exception as e: - log.error(f"Error in generic_query_tool: {str(e)}") - return f"Error handling generic query: {str(e)}" - # Store fetch_cycle_details in self for later access by _get_selected_tools self._fetch_cycle_details_tool = fetch_cycle_details - return [ask_for_clarification, vector_search_tool, structured_db_tool, pages_search_tool, docs_search_tool, generic_query_tool] + return [ + ask_for_clarification, + vector_search_tool, + structured_db_tool, + pages_search_tool, + docs_search_tool, + fetch_cycle_details, + ] - async def _get_selected_tools( + async def _create_tools_for_ask_mode( self, db, - selected_agents, user_meta, workspace_id, workspace_slug, @@ -1171,112 +1129,17 @@ Provide concise, relevant context from the attachment(s):""" conversation_history, message_id, is_project_chat=None, - pi_sidebar_open=None, - sidebar_open_url=None, + workspace_in_context=True, + chatbot_instance=None, ): - """Get tools based on selected agents, with dynamic OAuth token retrieval for action agents.""" + """Create tools for ask mode. - # Check for action executor agent - is_action_agent = any(agent_query.agent == Agents.PLANE_ACTION_EXECUTOR_AGENT for agent_query in selected_agents) - if is_action_agent: - # Build context for selector and executor - prefer provided workspace_slug; resolve only if missing - if not workspace_slug: - from pi.app.api.v1.helpers.plane_sql_queries import get_workspace_slug - - workspace_slug = await get_workspace_slug(workspace_id) - if not workspace_slug: - log.warning(f"Could not resolve workspace slug for workspace {workspace_id}") - return self._create_auth_error_tools(f"Could not resolve workspace information for {workspace_id}") - - # Get OAuth token for user and workspace (development mode will return hardcoded API key) - access_token = await self._get_oauth_token_for_user(db, user_id, workspace_id) - - if not access_token: - # No valid OAuth token - return special "auth required" tool - log.info(f"OAuth required for user {user_id} in workspace {workspace_id}") - return self._create_auth_required_tools( - workspace_id, - user_id, - chat_id, - str(message_id), - is_project_chat=is_project_chat, - project_id=project_id, - pi_sidebar_open=pi_sidebar_open, - sidebar_open_url=sidebar_open_url, - workspace_slug=workspace_slug, - ) - - # Initialize the executor with OAuth token or API key - action_tools: list = [] - try: - from pi.services.actions.category_selector import CategorySelector - from pi.services.actions.method_executor import MethodExecutor - from pi.services.actions.plane_actions_executor import PlaneActionsExecutor - - # Create actions executor - if access_token and access_token.startswith("plane_api_"): - actions_executor = PlaneActionsExecutor(api_key=access_token, base_url=settings.plane_api.HOST) - else: - actions_executor = PlaneActionsExecutor(access_token=access_token, base_url=settings.plane_api.HOST) - - # Create hierarchical tools - category_selector = CategorySelector() - method_executor = MethodExecutor(actions_executor) - - @tool - async def get_available_plane_actions(user_intent: str) -> str: - """ - Return available Plane API methods across all categories (advisory catalog). - Used to inform the LLM router; does not decide categories itself. - Categories include: cycles, workitems (for issues/tasks), projects, labels, states, modules, pages, assets, users, intake, members, activity, attachments, comments, links, properties, types, worklogs. - """ # noqa: E501 - categories = category_selector.get_available_categories() - - lines: list[str] = [] - lines.append("Available Plane action categories and methods:\n") - for cat, description in categories.items(): - try: - cat_methods = method_executor.get_category_methods(cat) - method_names = ", ".join(cat_methods.keys()) if cat_methods else "-" - except Exception as exc: - log.warning(f"Failed to get methods for category '{cat}': {exc}") - method_names = "(error retrieving methods)" - lines.append(f"- {cat}: {description}") - lines.append(f" Methods: {method_names}") - - return "\n".join(lines) - - action_tools = [get_available_plane_actions] - - except Exception as e: - log.error(f"Error creating action tools: {e}") - return self._create_auth_error_tools(str(e)) - - # ALSO include existing retrieval tools so LLM can mix retrieval + actions - all_static_tools = self._create_tools( - db, - user_meta, - workspace_id, - project_id, - user_id, - chat_id, - query_flow_store, - conversation_history, - message_id, - is_project_chat=is_project_chat, - ) - # Combine action tools with retrieval tools - combined_tools = action_tools + all_static_tools - return combined_tools - - # Default: static retrieval tools - agent_to_tool_map = { - Agents.PLANE_STRUCTURED_DATABASE_AGENT: "structured_db_tool", - Agents.PLANE_VECTOR_SEARCH_AGENT: "vector_search_tool", - Agents.PLANE_PAGES_AGENT: "pages_search_tool", - Agents.PLANE_DOCS_AGENT: "docs_search_tool", - Agents.GENERIC_AGENT: "generic_query_tool", - } + Args: + workspace_in_context: If False, filters out workspace-specific tools like + structured_db_tool, vector_search_tool, pages_search_tool, fetch_cycle_details, + entity search tools, and ask_for_clarification. Only keeps docs_search_tool. + """ + # Create all the retrieval tools except the generic query tool, which is no longer needed in ask mode all_tools = self._create_tools( db, user_meta, @@ -1289,15 +1152,19 @@ Provide concise, relevant context from the attachment(s):""" message_id, is_project_chat=is_project_chat, ) - selected_tool_names = [agent_to_tool_map.get(agent_query.agent) for agent_query in selected_agents] - tools = [tool for tool in all_tools if tool.name in selected_tool_names] - # Inject entity search tools and fetch_cycle_details so retrieval-only flows can disambiguate entities - # and get cycle details without SQL generation - try: + # Filter tools based on workspace_in_context + if workspace_in_context: + # Include all workspace-specific tools when workspace is in context + retrieval_tools = [ + tool + for tool in all_tools + if tool.name in ["vector_search_tool", "structured_db_tool", "pages_search_tool", "docs_search_tool", "fetch_cycle_details"] + ] + clarification_tool = next((t for t in all_tools if getattr(t, "name", "") == "ask_for_clarification"), None) + # Add all the entity search tools from pi.services.actions.tools.entity_search import get_entity_search_tools - # Build minimal context for entity search and cycle details tool search_ctx = { "workspace_id": workspace_id, "workspace_slug": workspace_slug, @@ -1305,33 +1172,35 @@ Provide concise, relevant context from the attachment(s):""" "user_id": user_id, } entity_tools = get_entity_search_tools(method_executor=None, context=search_ctx) or [] - log.info(f"ChatID: {chat_id} - Got {len(entity_tools)} entity search tools") + retrieval_tools.extend(entity_tools) - # Add fetch_cycle_details tool (stored in self during _create_tools) - if hasattr(self, "_fetch_cycle_details_tool"): - entity_tools.append(self._fetch_cycle_details_tool) - log.info(f"ChatID: {chat_id} - Added fetch_cycle_details to multi-agent tools") - else: - log.warning(f"ChatID: {chat_id} - fetch_cycle_details tool not available (was _create_tools called?)") + # add projects_retrieve tool till the retrieval and entity search tools are merged + method_executor, context, workspace_slug = await build_method_executor_and_context( + chatbot_instance=chatbot_instance, + user_id=user_id, + workspace_id=workspace_id, + project_id=project_id, + conversation_history=conversation_history, + user_meta=user_meta, + is_project_chat=is_project_chat, + chat_id=chat_id, + db=db, + ) + project_tools = get_tools_for_category("projects", method_executor=method_executor, context=context) or [] + project_tools = [t for t in project_tools if getattr(t, "name", "") in ["projects_retrieve"]] + retrieval_tools.extend(project_tools) - # Merge while avoiding duplicates by name - existing = {getattr(t, "name", "") for t in tools} - for t in entity_tools: - if getattr(t, "name", "") not in existing: - tools.append(t) - log.info(f"ChatID: {chat_id} - After adding entity tools, total tools: {len(tools)}, names: {[t.name for t in tools]}") - except Exception as e: - # Best-effort only; skip if unavailable - log.error(f"ChatID: {chat_id} - Error adding entity tools: {e}") - pass - # Always include clarification tool for retrieval flows so LLM can resolve ambiguity - try: - clar_tool = next((t for t in all_tools if getattr(t, "name", "") == "ask_for_clarification"), None) - if clar_tool and all(getattr(t, "name", "") != "ask_for_clarification" for t in tools): - tools = [clar_tool] + tools - except Exception: - pass - return tools + return retrieval_tools + [clarification_tool] if clarification_tool else retrieval_tools + else: + # When workspace is not in context, only include non-workspace-specific tools + log.info(f"ChatID: {chat_id} - Workspace not in context, filtering out workspace-specific tools") + retrieval_tools = [ + tool + for tool in all_tools + if tool.name in ["docs_search_tool"] # Only general documentation search + ] + # Don't include ask_for_clarification as it's designed for workspace entity clarifications + return retrieval_tools def _build_method_tools(self, category: str, method_executor, context: dict): """Build method-specific tools for the selected category using modular structure""" @@ -1414,149 +1283,6 @@ Provide concise, relevant context from the attachment(s):""" return title - @llm_error_handler( - fallback_message="I'm having trouble processing your request right now. Please try again later.", max_retries=2, log_context="[GENERIC_QUERY]" - ) - async def handle_generic_query( - self, - query: str, - user_id: str, - conversation_history: list[BaseMessage], - user_meta: Optional[dict[str, Any]] = None, - attachment_blocks: Optional[List[Dict[str, Any]]] = None, - include_user_context_on_followup: bool = False, - ) -> Dict[str, Any]: - """ - Handle generic queries using LLM without database or vector search. - input: query - user query - output: Generic response based on LLM knowledge only - """ - if user_meta is None: - user_meta = {} - date_time_context = await get_current_timestamp_context(user_id) - first_name = user_meta.get("first_name") or user_meta.get("firstName", "") - last_name = user_meta.get("last_name") or user_meta.get("lastName", "") - - # Compose user context for possible use in system prompt - user_only_context = f"**User's Firstname**: {first_name} and Lastname: {last_name}" - - # Compose message content with attachments if present - time_context_query = f"{query}\n\n**Context**: {date_time_context}" - message_content = time_context_query if not attachment_blocks else format_message_with_attachments(time_context_query, attachment_blocks) - - recent_history = [(m.type, m.content) for m in conversation_history] - system_prompt = GENERIC_PROMPT - - if conversation_history: - system_prompt = f"{system_prompt}\n\n{date_time_context}" - if include_user_context_on_followup: - log.debug("[GENERIC_QUERY] Adding user context on follow-up (include_user_context_on_followup=True)") - system_prompt = f"{system_prompt}\n\n{user_only_context}" - system_prompt = f"{system_prompt}\n\nSkip greetings and get straight to the point." - else: - system_prompt = f"{system_prompt}\n\n{date_time_context}\n\n{user_only_context}" - - recent_history = [(m.type, m.content) for m in conversation_history] - - generic_prompt = ChatPromptTemplate.from_messages([ - ("system", system_prompt), - ("placeholder", "{recent_history}"), - ("human", "{question}"), - ]) - - # Set tracking context for generic query - if self._token_tracking_context: - self.chat_llm.set_tracking_context( - self._token_tracking_context["message_id"], - self._token_tracking_context["db"], - MessageMetaStepType.COMBINATION, - chat_id=self._token_tracking_context.get("chat_id"), - ) - - # Use LLM chain - generic_llm_chain = generic_prompt | self.chat_llm - llm_response = await generic_llm_chain.ainvoke({"question": message_content, "recent_history": recent_history}) - response_content = llm_response.content if hasattr(llm_response, "content") else str(llm_response) - - return StandardAgentResponse.create_response(response_content) - - async def handle_generic_query_stream( - self, - query: str, - user_id: str, - conv_history: list[BaseMessage], - user_meta: dict[str, Any], - non_plane: bool = False, - include_user_context_on_followup: bool = False, - attachment_blocks: Optional[List[Dict[str, Any]]] = None, - ) -> AsyncIterator[str]: - date_time_context = await get_current_timestamp_context(user_id) - first_name = user_meta.get("first_name") or user_meta.get("firstName", "") - last_name = user_meta.get("last_name") or user_meta.get("lastName", "") - - user_only_context = f"**User's Firstname**: {first_name} and Lastname: {last_name}" - - if non_plane: - system_prompt = generic_prompt_non_plane - else: - system_prompt = GENERIC_PROMPT - - if conv_history: - system_prompt = f"{system_prompt}\n\n{date_time_context}" - if include_user_context_on_followup: - log.debug("[GENERIC_QUERY_STREAM] Adding user context on follow-up (include_user_context_on_followup=True)") - system_prompt = f"{system_prompt}\n\n{user_only_context}" - system_prompt = f"{system_prompt}\n\nSkip greetings and get straight to the point." - else: - system_prompt = f"{system_prompt}\n\n{date_time_context}\n\n{user_only_context}" - - # Filter out any non-BaseMessage objects (defensive check) - valid_conv_history = [m for m in conv_history if isinstance(m, BaseMessage)] - if len(valid_conv_history) != len(conv_history): - log.warning(f"[GENERIC_QUERY_STREAM] Filtered out {len(conv_history) - len(valid_conv_history)} non-BaseMessage items from conv_history") - recent_history = [(m.type, m.content) for m in valid_conv_history] - - # Format message content with attachments if present - message_content = query if not attachment_blocks else format_message_with_attachments(query, attachment_blocks) - - generic_prompt = ChatPromptTemplate.from_messages([ - ("system", system_prompt), - ("placeholder", "{recent_history}"), - ("human", "{question}"), - ]) - - # Set tracking context for generic query - if self._token_tracking_context: - self.chat_llm.set_tracking_context( - self._token_tracking_context["message_id"], - self._token_tracking_context["db"], - MessageMetaStepType.COMBINATION, - chat_id=self._token_tracking_context.get("chat_id"), - ) - - # Use LLM chain for streaming - generic_llm_chain = generic_prompt | self.chat_llm - - # Use streaming error handler context manager - async with streaming_error_handler("[GENERIC_QUERY_STREAM]") as error_context: - try: - if non_plane: - stream_generator = generic_llm_chain.astream( - {"question": message_content, "recent_history": recent_history}, temperature=NON_PLANE_TEMPERATURE - ) - else: - stream_generator = generic_llm_chain.astream({"question": message_content, "recent_history": recent_history}) - - async for chunk in stream_generator: - error_context.add_chunk(chunk) - content = chunk.content if hasattr(chunk, "content") else str(chunk) - yield content - - except Exception: - # Error was handled by context manager, yield fallback message - fallback_message = "I'm having trouble processing your request right now. Please try again later." - yield fallback_message - async def handle_structured_db_query( self, db: AsyncSession, @@ -1569,7 +1295,7 @@ Provide concise, relevant context from the attachment(s):""" chat_id: Optional[str] = None, vector_search_issue_ids: Optional[List[str]] = None, vector_search_page_ids: Optional[List[str]] = None, - is_multi_agent: Optional[bool] = False, + is_multi_tool: Optional[bool] = False, user_meta: Optional[Dict[str, Any]] = None, conv_history: Optional[List[str]] = None, preset_tables: Optional[List[str]] = None, @@ -1585,6 +1311,9 @@ Provide concise, relevant context from the attachment(s):""" user_meta = {"time_context": timestamp_context} log.info(f"Processing structured DB query: {query}") + # Import here to avoid circular import + from pi.agents.sql_agent import text2sql + intermediate_results, response_data = await text2sql( db, query, @@ -1596,7 +1325,7 @@ Provide concise, relevant context from the attachment(s):""" chat_id, vector_search_issue_ids, vector_search_page_ids, - is_multi_agent, + is_multi_tool, user_meta, conv_history, preset_tables, @@ -1608,12 +1337,13 @@ Provide concise, relevant context from the attachment(s):""" query_execution_result: str = response_data.get("results", "") entity_urls = await self._create_entity_urls_for_db_search(query_execution_result, query_flow_store, intermediate_results, chat_id) - + log.info(f"ChatID: {chat_id} - kit.py:handle_structured_db_query: Entity URLs: {entity_urls}") # Format results into a string, passing SQL query so we can detect LIMIT clauses sql_query_for_format = intermediate_results.get("generated_sql") if isinstance(intermediate_results, dict) else None formatted_query_result = await format_as_bullet_points(query_execution_result, sql_query_for_format) - return StandardAgentResponse.create_response(formatted_query_result, entity_urls, intermediate_results=intermediate_results) + result = StandardAgentResponse.create_response(formatted_query_result, entity_urls, intermediate_results=intermediate_results) + return result async def handle_vector_search_query( self, query: str, workspace_id: str, project_id: str, user_id: str, vector_search_issue_ids: list[str] @@ -1797,18 +1527,20 @@ Provide concise, relevant context from the attachment(s):""" # log.info(f"Formatting {len(retrieved_docs)} {doc_type} results") formatted_results = [] - + log.info(f"kit.py:format_retrieved_results: Formatting {len(retrieved_docs)} {doc_type} results") + log.info(f"kit.py:format_retrieved_results: Retrieved docs: {retrieved_docs}") for doc in retrieved_docs: if doc_type == "issues": - title = doc.metadata.get("title", "Untitled Issue") + # Accept both legacy and new metadata keys + title = doc.metadata.get("title") or doc.metadata.get("name") or "Untitled Issue" issue_id = doc.metadata.get("issue_id", "Unknown ID") - project_name = doc.metadata.get("project__name", "Unknown Project") - state = doc.metadata.get("state__name", "Unknown State") + project_name = doc.metadata.get("project_name", "Unknown Project") + state = doc.metadata.get("state_name", "Unknown State") priority = doc.metadata.get("priority", "Unknown Priority") # Chunk metadata chunk_type = doc.metadata.get("chunk_type", "content") - issue_content = doc.page_content.strip() + issue_content = (doc.page_content or "").strip() formatted_results.append( f"**Issue: {title}** (ID: {issue_id})\n" @@ -1817,11 +1549,11 @@ Provide concise, relevant context from the attachment(s):""" ) elif doc_type == "pages": - page_name = doc.metadata.get("name", "Untitled Page") + page_name = doc.metadata.get("name") or doc.metadata.get("page_title") or "Untitled Page" page_id = doc.metadata.get("page_id", "Unknown ID") - project_name = doc.metadata.get("project__name", "Unknown Project") + project_name = doc.metadata.get("project_name", "Unknown Project") - page_content = doc.page_content.strip() + page_content = (doc.page_content or "").strip() formatted_results.append(f"**Page: {page_name}** (ID: {page_id})\n" f"Project: {project_name}\n" f"Content: {page_content}\n") diff --git a/apps/pi/pi/services/chat/prompts.py b/apps/pi/pi/services/chat/prompts.py index cb06d68260..b6b824245b 100644 --- a/apps/pi/pi/services/chat/prompts.py +++ b/apps/pi/pi/services/chat/prompts.py @@ -89,320 +89,30 @@ One unlocks security, governance, and project management features scale-ups need \n""" -router_prompt = f"""Role: You are an advanced query processing assistant for Plane, a project management tool. -Your job is to route the user's question to the relevant support agents and decompose it into context-aware sub-queries for each agent. +# In pi/services/chat/prompts.py, add this constant: +RETRIEVAL_TOOL_DESCRIPTIONS = """ +**Retrieval Tool Capabilities:** -Context: -{plane_context} +1. **vector_search_tool**: For semantic search ONLY on work-item title and description fields. + - USE FOR: Finding work items by content, topics, keywords, concepts + - NOT FOR: Comments, updates, activity streams, state changes, metadata queries, presentation/output-formatting instructions + - RETURNS: Text results and a list of work-item IDs -You will receive conversation history that includes not just questions and answers, but also internal reasoning about what data was retrieved and how queries were processed, including: -• **Tool Execution Results**: SQL queries, semantic search results, entity matches -• **Entity URLs**: Specific IDs, types, and identifiers (work item (issue) keys like PROJ-123, issue_ids as UUIDs, project IDs, page IDs) -• **Agent Selection**: Which support agents were previously chosen -• **Final Answers**: Previous assistant responses +2. **structured_db_tool**: For pulling structured data from Plane's database using natural language queries. + - USE FOR: Filtering by metadata (assignees, states, dates, projects), aggregations, relationships, counts + - NOT FOR: Semantic text search (use vector_search_tool instead) + - ACCEPTS: Natural language query (e.g., "show me all high priority bugs assigned to John") and optional issue_ids/page_ids from prior searches + - NOTE: This tool is a text2sql tool - it converts your natural language to SQL internally using Plane's database schema knowledge -**CRITICAL - Work Item Key Recognition:** -Work item unique keys follow the format: `PROJECT_IDENTIFIER-SEQUENCE_NUMBER` (e.g., PROJ-123, PULSE-45, MOB-1). -- **VALID KEYS**: PROJ-123, ABC-45, PULSE-1, MOBILE-789, etc. (project identifier + hyphen + number) -- **NOT KEYS**: PULSE, ABC, PROJ (standalone words without hyphen and sequence number) -- **Key Detection Rule**: A work item key MUST contain a hyphen (-) followed by a number -- **When user mentions standalone words**: Treat as general terms, NOT as work item keys +3. **pages_search_tool**: For semantic search in content of Plane Pages (notepad). + - USE FOR: Finding pages by content, topics, concepts + - NOT FOR: Page metadata queries (who created, when created) + - RETURNS: Text results and a list of page IDs -Support Agents: - - 1. generic_agent: For non-retrieval tasks and general conversation. Use when: - • The question is unrelated to Plane (pleasantries, small talk, general topics) - • The user asks for formatting or rephrasing of prior answers - • The answer can be produced directly from the conversation history without any new lookup - • The request is security-sensitive (passwords, API keys) — this agent will refuse - **DO NOT USE for**: Product docs/terminology (send to plane_docs_agent) - 2. plane_structured_database_agent: For pulling structured data from Plane's database using SQL queries. - **STRENGTHS**: Filtering by metadata (assignees, states, dates, projects), aggregations, relationships, counts - **IMPORTANT**: Do NOT ask this agent to search for text content - that's handled by semantic search agents - **AVOID**: Queries like "find issues mentioning X" - instead ask for "issues assigned to me" and let vector search find the "mentioning X" part - **FIELD SCHEMA NOTE**: Work items only have `title` and `description` fields. When user asks for "summary", they want the description field (or a brief excerpt), NOT a non-existent "summary" field - 3. plane_vector_search_agent: For semantic search ONLY on issue title and description fields. - **STRENGTHS**: Finding issues by content, topics, keywords, concepts - **SEND**: Core keywords/concepts only, remove filler words like "details about", "information on" - **AVOID**: Metadata requests (assignees, states, dates) - that's handled by structured database agent - **CRITICAL - Activity Data**: Vector search NEVER handles: - - Comments, updates, activity feeds - - State changes, assignments changes - - Temporal queries ("latest", "recent", "last") - - These are all structured database records, not searchable content - **IMPORTANT**: Don't use this agent for output-formatting instructions. Only use vector search when the user specifies content topics/keywords to match in title/description. - - **GATING RULE — Content vs Presentation**: - - Invoke this agent only when the user specifies concrete content topics/keywords to match in title/description. - - Requests that ask to transform or present existing fields (without specifying content topics) are presentation-only and must NOT trigger this agent. - - Heuristic guard: If, after removing operation/formatting verbs and field names, no content topic remains (or the candidate query is generic/empty), skip semantic search. - - 4. plane_pages_agent: For semantic search in content of Plane Pages (notepad). - **STRENGTHS**: Finding pages by content, topics, concepts - **SEND**: Core keywords/concepts only, remove filler words - **USE WHEN**: Query specifically asks about page content, not page metadata - 5. plane_docs_agent: For searching Plane's official documentation and answering questions about Plane. - **STRENGTHS**: Finding documentation by topics, features, how-to guides, terminology explanations, concept definitions - **USE WHEN**: User asks about Plane features, terminology (e.g., "what is X in Plane?"), concepts, functionality, or how things work - **SEND**: Core feature/topic names only, remove words like "how to", "documentation about" - 6. plane_action_executor_agent: For executing actions that create, update, or delete entities in Plane. - **STRENGTHS**: Creating issues, updating work item states, assigning users, managing projects, cycles, and modules - **USE WHEN**: User explicitly requests actions using action verbs: - - Supported actions: create, add, update, change, modify, delete, assign, move, archive, remove, set, link - - Unsupported actions that should STILL route here: generate, plot, visualize, chart, export, sync, integrate, upload, bulk delete - **WHY ROUTE UNSUPPORTED ACTIONS**: The action executor will properly reject unsupported requests (analytics, integrations, bulk ops, etc.) with a helpful message. Do NOT route these to retrieval agents. - **AVOID**: Pure read-only queries like "show me", "list", "find", "what is" (use retrieval agents instead) and complex multi-step workflows without clear action intent - **SEND**: Clear action intent with entity details, include specific parameters like titles, descriptions, assignees - **PLURALITY PRESERVATION (CRITICAL)**: - - When the user asks to create/add "work items" (plural) without providing an explicit list of titles, DO NOT collapse the intent to a single item. - - Preserve the plurality in the decomposed query and pass the theme/topic of the requested items (e.g., "create multiple work items themed ''"). - - Never set a work-item title to be identical to the project name when the user intent is plural or thematic. - - Avoid binding a single specific title unless the user explicitly provided one; leave item names for the planning phase to generate. - -**CRITICAL DECOMPOSITION PRINCIPLES:** - -**Entity Type Recognition - Prevent False Semantic Search:** -- **Plane Entity Types** (NOT content keywords): work items, projects, cycles, modules, epics, initiatives, pages, labels, states, users, members -- When user mentions entity types + metadata constraints (e.g., "recent module", "active projects", "completed cycles"), this is a STRUCTURED QUERY, not semantic search -- **Pattern**: "[temporal/status adjective] + [entity type]" → Use structured_db_agent, NOT semantic search -- **Examples of Entity Queries (NO semantic search)**: - - "current module" → structured query for modules which is currently running based on start_date and end_date - - "active projects" → structured query for projects with active status - - "completed cycles" → structured query for cycles in completed state - - "my open work items" → structured query for work items assigned to me in open states -- **Only use semantic search when**: User specifies a content topic WITHIN the entity's title/description fields (e.g., "modules mentioning API refactor") - -**Specific Entity ID Recognition - Skip Semantic Search:** -- **When a specific entity ID/UUID is provided in the query** (e.g., "page with id: 70cdf795-...", work item key "PROJ-123"), semantic search is UNNECESSARY -- **For such queries**: Use ONLY the structured_db_agent to retrieve the specific entity by its ID -- **Semantic search should ONLY find entities**, not retrieve already-identified entities -- **Examples of queries that should NOT use semantic search**: - - "summarise [page with id: UUID]" → Only structured_db_agent (retrieve by ID, let final answer do summarization) - - "what's the status of PROJ-123" → Only structured_db_agent (retrieve by key) - - "show me details of [specific entity with UUID]" → Only structured_db_agent -- **Use semantic search ONLY when**: User wants to FIND entities by content, not retrieve a specific already-identified entity - -**Conversation History Context - Avoid Redundant Searches:** -- **Before routing to any search agent**, check if the conversation history already contains the needed information -- **Pronouns and references** ("it", "that page", "those items", "the module we discussed") refer to entities in conversation history -- **For follow-up questions about previously retrieved data**: If the conversation history contains the information needed to answer, DO NOT trigger new searches - - When the answer is fully present in conversation history: - • Select only `generic_agent` with a short directive like "answer from conversation history" - -**Division of Labor - Avoid Duplication:** -- **Semantic Search Agents** (vector_search, pages, docs) handle content/text matching -- **SQL Agent** handles metadata filtering, relationships, aggregations - WITHOUT duplicating text search -- **Action Executor Agent** handles create/update/delete operations - NOT read-only queries -- **When using multiple agents**: Split responsibilities cleanly - don't ask both agents to do the same thing -- **Vector Search ONLY for**: - Issue/page titles and descriptions - **NOT for**: Comments, updates, activity, state changes, metadata, temporal queries, and entity-type queries with metadata constraints - -**Result-Set Referencing Pattern (MUST):** -- When one agent needs to build on the output of a previous agent, **refer to those prior results by their IDs (wrapped in back-ticks) _or_ by a neutral phrase such as "those work items", "those pages", "those items"**. -- **NEVER** re-introduce the original text-search keywords (e.g., "mobile UI", "Presidency") in a follow-up SQL query once a semantic search agent has already handled the content matching. -- If IDs are available from the earlier agent, include them like: `issue_ids` `id1`, `id2`. -- Examples: - • Bad ❌ `plane_structured_database_agent`: "pending work items assigned to me **mentioning authentication**" - • Good ✅ `plane_structured_database_agent`: "Pages created by me in the last 24 hours **from those pages**" - • Good ✅ `plane_structured_database_agent`: "Current status of work items with issue_ids `123e...`, `456e...`" -- This rule applies symmetrically: if the SQL agent runs first and returns IDs, later semantic search agents must reference those IDs. - -**Query Optimization by Agent Type:** -- **CRITICAL GATING - Content vs Presentation**: Before routing to semantic search, apply this test: - • Does the query specify concrete content topics/keywords to MATCH in title/description? → Use semantic search - • Does the query only ask to DISPLAY/FORMAT existing fields without content matching? → DO NOT use semantic search - • Heuristic: If after removing operation/formatting verbs and field names, no content topic remains → Skip semantic search entirely -- **For Semantic Search**: ONLY when content topics are specified, extract core keywords/concepts, remove filler words ("details about", "information on", "tell me about") -- **For SQL Agent**: Focus on structured filtering (assignees, states, projects, dates) and relationships; can also handle field-retrieval without content matching -- **CRITICAL - Count Questions**: When user asks "how many", you MUST create exactly TWO queries to plane_structured_database_agent (not one): 1) Count query 2) List query -- **Examples of Filler Words to Remove**: "details about", "information on", "tell me about", "show me", "what is", "how to" - -**Multi-Agent Strategy:** -- **If text search needed**: Use semantic search agent for content matching -- **If metadata needed**: Use SQL agent for filtering/aggregation -- **For Count Questions**: ALWAYS create TWO queries to plane_structured_database_agent when user asks "how many" - one count query + one list query -- **Combine cleanly**: Let each agent do what it does best, avoid overlap - -**Default Incomplete Work Scope:** -- When the user's phrasing implies items that should be worked on now or are pending (e.g., "to pick up", "open", "pending", "to-do", "missed to add", "should be in this cycle", "not yet scheduled"), and the user does not explicitly ask for completed/closed items, assume the user means uncompleted work. -- In such cases, include an uncompleted-state filter in the structured query: state bucket/group IN (backlog, unstarted, started); exclude completed/cancelled. -- Prefer filtering by the canonical state bucket/group if available; otherwise, use state names as a fallback in a case-insensitive manner. -- Combine this with any other constraints (e.g., membership, priority, assignees, dates) required by the user intent. - -**Priority Canonicalization:** -- Canonical priority values are: urgent, high, medium, low (case-insensitive). Do not invent other names like "highest". -- Map common synonyms in user phrasing to canonical values: "highest"/"critical"/"blocker"/"p0" → urgent; "very high"/"p1" → high; "normal"/"standard" → medium; "lowest" → low. -- When decomposing to structured_db_tool, phrase priority filters using the canonical values and prefer canonical ordering urgent → high → medium → low when sorting. -- For phrases like "highest priority" or "top priority" without an explicit priority name, do NOT equate to "urgent". Decompose as: order by canonical priority (urgent → high → medium → low) and return the top item(s). - -Context-Aware Routing and Decomposition Instructions: - - - **Context Analysis**: Before routing, analyze the conversation history to understand what entities (issues, projects, pages) were previously discussed and retrieved - - **Entity Resolution**: When decomposing queries, resolve pronouns ("it", "that", "those", "them") and references to specific entities from conversation context: - • Map "that issue" → specific issue with issue_id `uuid` and issue key (e.g., PROJ-123) - • Map "same project" → specific project name/ID from previous context - • Map "those items" → specific entity IDs from previous tool results - - **UUID Handling**: When including UUIDs in decomposed queries, wrap them in back-ticks (e.g., `123e4567-e89b-12d3-a456-426614174000`) - - **Scope Inheritance**: If previous queries focused on specific workspaces/projects, maintain that scope in decomposed queries when relevant - - **Preserve Intent**: Keep the user's action intent and narrative voice (first/second/third person) in decomposed queries - - **Agent-Specific Decomposition**: - • Route each sub-query to the most appropriate agent(s) - • Use the generic_agent for pleasantries or queries entirely unrelated to Plane - • Use plane_structured_database_agent for structured data queries (include specific entity IDs when available from context) - • Use plane_vector_search_agent only for semantic search on issue titles/descriptions - • Use plane_pages_agent for semantic search in page content - • Use plane_docs_agent for documentation searches - • Use plane_action_executor_agent for create/update/delete operations (include specific entity details and action parameters) - - **Multiple Agents**: Select multiple agents if the query spans multiple capabilities - - **Query Clarity**: Ensure decomposed queries are clear, concise, and include context-specific details for each agent - - **No Plane References**: No need to mention 'in Plane', 'on Plane', etc., in decomposed queries - - **Literal Copy Rule**: If any part of the user query contains back-ticked content, copy it exactly in decomposed queries - - **Detecting Limited Lists**: - When analyzing conversation history, look at the SQL query text that accompanies any previous `plane_structured_database_agent` results. - • If you see a `LIMIT ` clause, treat the corresponding list in the assistant's answer as *truncated*. - • For follow-up questions that ask for counts, distributions, or other aggregations on "those items", **do NOT** restrict yourself to the limited IDs. - Instead, re-decompose a fresh query that applies the same filtering criteria **without a LIMIT clause** so you operate on the full data set, unless the user explicitly states they want only that subset. - - **CRITICAL - Work Management Terminology Resolution **: Always translate user terminology to Plane's official terms using TERMINOLOGY BRIDGE above (e.g., "tickets" → "work items", "tasks" → "work items") -Key Context-Enhancement Rules: - - **Work Item Key Recognition**: Apply the key detection rule above - only treat hyphenated identifiers (PROJ-123, PULSE-45) as work item keys. Standalone words (PULSE, ABC, PROJ) should be treated as general terms for semantic search - - **Issue Keys vs UUIDs**: When user mentions 'issue ID', they typically mean the issue key (PROJ-123), but include both the issue key and issue_id (UUID) in decomposed queries when available from context - - **Early Exit**: If the query has no pronouns, no cross-references ("above", "previous", "that"), and shares no entities with recent context, treat as standalone query - - **Entity Specificity**: Add specific identifiers from conversation context to make decomposed queries more precise - -Examples: - -**Query Optimization - Remove Filler Words:** - 1. Question: "Details about customers feature" - Decomposed Queries: - - plane_docs_agent: "customers feature" # Removed "details about" - - 2. Question: "Tell me about API integration documentation" - Decomposed Queries: - - plane_docs_agent: "API integration" # Removed "tell me about" and "documentation" - -**CRITICAL - Field Schema & Presentation (NO semantic search for field-display requests):** - 3. Question: "Work items assigned to me, based on priority and summary from description if any" - Decomposed Queries: - - plane_structured_database_agent: "List work items assigned to me in uncompleted states, ordered by priority, include title and description fields" - # NO semantic search! "summary from description" means show the description field (or brief excerpt), not search for "summary" as a topic - # Work items only have title and description fields - there is no separate "summary" field - - 4. Question: "Show my tasks with their titles and states" - Decomposed Queries: - - plane_structured_database_agent: "List work items assigned to me with title and state fields" - # NO semantic search! This only requests field retrieval, no content topic to match - -**CRITICAL - Entity Type Queries (NO semantic search for entity + metadata patterns):** - 5. Question: "details about our recently concluded module" - Decomposed Queries: - - plane_structured_database_agent: "Show details of recently concluded modules in the current project, ordered by end date descending, limit to most recent" - # NO semantic search! "recently concluded module" is entity type (module) + temporal constraint (recently concluded), NOT a content search - - 6. Question: "active projects in my workspace" - Decomposed Queries: - - plane_structured_database_agent: "List active projects in the workspace" - # NO semantic search! "active projects" is entity type + status constraint, NOT content search - - 7. Question: "completed cycles this quarter" - Decomposed Queries: - - plane_structured_database_agent: "List cycles that completed in the current quarter" - # NO semantic search! "completed cycles" is entity type + temporal constraint, NOT content search - -**Division of Labor - Multi-Agent Scenarios:** - 8. Question: "Issues that mention opensearch that are assigned to me and the modules in which they are" - Decomposed Queries: - - plane_vector_search_agent: "opensearch" # Handles content search - - plane_structured_database_agent: "work items assigned to me along with their modules" # Handles metadata filtering - - 9. Question: "How many issues assigned to John are in the backlog and mention 'mobile UI'?" - Decomposed Queries: - - plane_vector_search_agent: "mobile UI" # Handles content search - - plane_structured_database_agent: "Count work items assigned to John that are in the backlog state" # Handles metadata - - 10. Question: "Show me high priority bugs from last week that mention authentication" - Decomposed Queries: - - plane_vector_search_agent: "authentication bugs" # Content search - - plane_structured_database_agent: "high priority issues created in the last week" # Metadata filtering - - 11. Question: "How many issues are in progress?" - Decomposed Queries: - - plane_structured_database_agent: "Count of work items in progress state" # Count query - - plane_structured_database_agent: "List work items in progress state" # List query - - 12. Question: "How many projects are there in this workspace?" - Decomposed Queries: - - plane_structured_database_agent: "Count of projects in the current workspace" # Count query - - plane_structured_database_agent: "List projects in the current workspace" # List query - -**Single Agent Examples:** - 13. Question: "What were my thoughts about the B2C channel for our product?" - Decomposed Queries: - - plane_pages_agent: "B2C channel product" # Core keywords only - - 14. Question: "How do I install Plane on my server?" - Decomposed Queries: - - plane_docs_agent: "install Plane server" # Removed "how do I" - - 15. Question: "How to create sprints and tranfer tickets from previous sprints to current one" - Decomposed Queries: - - plane_docs_agent: "create cycles" # Translated "sprints" to "cycles" - - plane_docs_agent: "tranfer work items from previous cycles to current one" # Translated "sprints" to "cycles", "tickets" to "work items" - -**Context-Enhanced Routing:** - 16. Previous Context: Found issue "PROJ-45: Fix login bug" with issue_id `123e4567-e89b-12d3-a456-426614174000` - Question: "Who is the assignee of that work-item?" - Decomposed Queries: - - plane_structured_database_agent: "Who is the assignee of the work-item with issue_id `123e4567-e89b-12d3-a456-426614174000`?" - - 17. Previous Context: Retrieved Mobile App project issues with IDs [`uuid1`, `uuid2`, `uuid3`] - Question: "What's the status of those items?" - Decomposed Queries: - - plane_structured_database_agent: "What's the status of work items with issue_ids `uuid1`, `uuid2`, `uuid3`?" - -**Action Executor Examples:** - 18. Question: "Create a new issue for the login bug we discussed" - Decomposed Queries: - - plane_action_executor_agent: "Create issue with title 'login bug' and description from discussion context" - - 19. Question: "Update the status of PROJ-123 to in progress and assign it to Sarah" - Decomposed Queries: - - plane_action_executor_agent: "Update work item PROJ-123 state to in progress and assign to Sarah" - - 20. Question: "Show me all high priority bugs and create a new cycle for them" - Decomposed Queries: - - plane_structured_database_agent: "List high priority work items with bug type" # First get the bugs - - plane_action_executor_agent: "Create new cycle for managing high priority bugs" # Then create cycle - -**Generic Agent Examples:** - 21. Question: "Format the above answer into bullet points" - Decomposed Queries: - - generic_agent: "Re-format the above answer into bullet points" - - 22. Question: "List all tables in the database" - Decomposed Queries: - - generic_agent: "List all tables in the database" - -**Docs Agent Examples (Plane Terminology & Concepts):** - 23. Question: "What is 'issue' equivalent in Plane?" - Decomposed Queries: - - plane_docs_agent: "issue equivalent" - - 24. Question: "What are cycles in Plane?" - Decomposed Queries: - - plane_docs_agent: "cycles" - -**FINAL REMINDER:** -- **ALWAYS** optimize queries for each agent's strengths -- **NEVER** duplicate the same search between multiple agents -- **CRITICAL**: DO NOT use semantic search for presentation/field-display requests - only use it when user specifies content topics to match -- **CRITICAL**: DO NOT use semantic search for entity-type queries with metadata constraints (e.g., "recently concluded module", "active projects") - these are structured queries -- **REMOVE** filler words from semantic search queries -- **SPLIT** responsibilities cleanly when using multiple agents -- **FOCUS** each query on what that specific agent does best -- **TRANSLATE** user terminology using the TERMINOLOGY BRIDGE above when decomposing queries (e.g., "tickets" → "work items") - -Remember to provide a decomposed query for each selected agent, using conversation context and following the decomposition principles above.""" # noqa: E501 +4. **docs_search_tool**: For searching Plane's official documentation. + - USE FOR: Finding documentation by topics, features, how-to guides + - RETURNS: Formatted documentation search results +""" # LLM prompt for action category routing (multi-select) @@ -430,6 +140,7 @@ Your task: Based on the user's intent and any advisory text (like method lists) - properties: Custom properties and fields for work items - types: Custom work item types (bug, task, story, etc.) - worklogs: Time tracking and work logs +- 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. @@ -437,6 +148,9 @@ Rules: - 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). - Provide a brief rationale per selection. +- If the user's request doesn't require any action, is about retrieving information from the database or a how-to guide, or is about planning the next steps, add the retrieval_tools category. + ** A brief description of the retrieval tools for your perusal:** + {RETRIEVAL_TOOL_DESCRIPTIONS} **CRITICAL - UNSUPPORTED REQUESTS (return empty list []):** If the user's request falls into any of these categories, return an EMPTY LIST immediately: @@ -448,6 +162,10 @@ If the user's request falls into any of these categories, return an EMPTY LIST i These requests cannot be fulfilled with the available action categories. Return [] so the system can provide a proper rejection message. +**Project Features:** + - Cycles, modules, pages, workitem types, views, intake, and time tracking are project-level features that are enabled/disabled on a per-project basis. + - If the user's request is to create any of these entities, or enable/disable any of these features, you MUST add 'projects' category to the selections. + Output Format: You MUST return a JSON object with a "selections" key containing an array of selection objects. @@ -469,7 +187,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 +- Valid category values: workitems, projects, cycles, labels, states, modules, pages, assets, users, intake, members, activity, attachments, comments, links, properties, types, worklogs, retrieval_tools - No explanation outside the JSON structure """ # noqa: E501 @@ -477,17 +195,15 @@ You MUST return a JSON object with a "selections" key containing an array of sel generic_prompt_non_plane = """Your name is Plane AI (formerly, Plane Intelligence (Pi). You are a helpful assistant. Use the user's first name naturally in conversation when it feels appropriate. CRITICAL: When the user asks you to summarize, reformat, or process content, focus on actual conversation content (messages, questions, answers) that the user has provided or that you have generated in response to their queries. Do not summarize system reminders, internal context tags, or technical metadata that are part of the system's internal operations. - -CRITICAL: Prefer presenting information in lists or simple formats. Only use tables when absolutely necessary or specifically requested.""" # noqa: E501 +""" # noqa: E501 generic_prompt = """You are a helpful assistant for the Plane project management tool. Your name is Plane AI (formerly, Plane Intelligence (Pi). Use the user's first name naturally in conversation when it feels appropriate. Refuse to provide sensitive information like passwords or API keys. However, you can reveal the name of the user to him/her in your response. CRITICAL: When the user asks you to summarize, reformat, or process content, focus on actual conversation content (messages, questions, answers) that the user has provided or that you have generated in response to their queries. Do not summarize system reminders, internal context tags, or technical metadata that are part of the system's internal operations. - -CRITICAL: Prefer presenting information in lists or simple formats. Only use tables when absolutely necessary or specifically requested.""" # noqa: E501 +""" # noqa: E501 combination_system_prompt = f"""You are a front-desk assistant at Plane, a project management tool. -Your name is Plane AI (formerly, Plane Intelligence (Pi)) and your job is to provide a coherent and comprehensive answer given the following user query, the decomposed queries sent to different agents, and their responses. +Your name is Plane AI (formerly, Plane Intelligence (Pi)) and your job is to provide a coherent and comprehensive answer given the following user query, the decomposed queries sent to different tools, and their responses. Use the user's first name naturally in conversation when it feels appropriate. @@ -495,26 +211,29 @@ Here is the context about Plane: {plane_context} Rules: -1. Never mention the use of multiple agents in your response. - Just give the answer, don't refer to the agents or the fact that the information is provided by them. +1. Never mention the use of multiple tools in your response. + Just give the answer, don't refer to the tools or the fact that the information is provided by them. 2. Ensure your answer directly addresses the user query. 3. **Terminology**: Always use "work-item" instead of "issue" when communicating with users. The backend may use "issue" in database tables and queries, but users should only see "work-item" terminology. 4. **Unique Keys**: Refer to work-item identifiers (like PAI-123, MOB-45) as "unique key" instead of "Issue ID" in user-facing responses. 5. Suppress the UUIDs (like User ID, Issue ID, Page ID, Project ID, Workspace ID, etc) in your response. These are PII data. Never show them. And don't mention the suppression in your response. However, remember that when the user mentions 'issue ID' or 'issue identifier' he/she mean to refer to the issue identifier which is not UUIDs but the unique key like PAI-123, MOB-45. - For example, if the user asks "What is the issue ID of the issue 'Support for Custom Fields'?", and you are provided with the unique key 'PAI-123' in the responses by the agents, your response should be "The issue ID of the issue 'Support for Custom Fields' is PAI-123". + For example, if the user asks "What is the issue ID of the issue 'Support for Custom Fields'?", and you are provided with the unique key 'PAI-123' in the responses by the tools, your response should be "The issue ID of the issue 'Support for Custom Fields' is PAI-123". You can reveal the name of the user to him/her in your response, if requested. -6. If the responses to the decomposed agent queries are empty, that means there is no data related to that question. +6. If the responses to the decomposed tool queries are empty, that means there is no data related to that question. Just convey this in your answer. Don't hallucinate. + - IMPORTANT: Never mention internal details like "SQL", "query execution", or "tools" when reporting no data. + - Phrase it in a user-friendly way tailored to the question, e.g., "No recent comments found on your work-items." + - Optionally offer one gentle next step (e.g., "You can widen the time range or adjust filters.") but keep it concise. 7. If the user asks for sensitive information, such as passwords, API keys, table names, or schema details, inform them that you cannot provide such data. 8. If the user query is about database tables or schemas, DO NOT reveal the actual table names or schema details in your response. 9. Remember the point 7 above, never provide table names or schema details in your response. -10. If PLANE_STRUCTURED_DATABASE_AGENT has been deployed to query the database: +10. If STRUCTURED_DB_TOOL has been deployed to query the database: - List all items retrieved with their relevant details (excluding UUIDs which must always be suppressed as per Rule 5). - DO NOT re-filter the results, as they have already been filtered using an appropriate SQL query based on the user query. - If the user query pertains to sensitive information (e.g., tables, schemas, or passwords), refer to point 8: never provide table names, schema details, or sensitive data in your response. - - The SQL query generated by the PLANE_STRUCTURED_DATABASE_AGENT will be shared with you for context, so you understand what data was extracted. **Never reveal this query to the end user**. + - The SQL query generated by the STRUCTURED_DB_TOOL will be shared with you for context, so you understand what data was extracted. **Never reveal this query to the end user**. 11. URL Embeddings: When you see entity names (like work-item names, page names, etc.) in the response that have corresponding URLs in the Entity URLs section, ALWAYS create clickable links using Markdown syntax. This is critical for user experience. - Identify the entity type from the Entity URLs section. - **Standard formatting for all contexts**: @@ -523,22 +242,18 @@ Rules: - **Exception for work-items in tables**: If the work-item name and unique identifier are in separate table columns, make the unique identifier column clickable using this format: [Unique Key](URL). - **For non-work-item entities in tables**: Always use the standard format Entity Name [view](URL), even in tables. - If the entity is not present in the Entity URLs section, or if the Entity URLs section is empty, don't create a link. -12. Data Presentation and Tables: - - **Prefer non-table formats**: Present information in lists, paragraphs, or simple formats whenever possible. Only use tables when: - - The data naturally requires comparison across multiple attributes - - The user specifically requests tabular format - - The information cannot be clearly presented in any other format - - **When tables are necessary**: +12. Data Presentation in Tables: + - **When presenting data in tables**: a) **CRITICAL: Never include UUIDs in any table cell - they must be suppressed.** b) Apply the URL embedding rules from Rule 11, including the table exception for separate columns. """ # noqa: E501 combination_user_prompt = """Provide a coherent and comprehensive answer given the following user query, -the decomposed queries sent to different agents, and their responses: +the decomposed queries sent to different tools, and their responses: User Query: {original_query} -Agent Queries and Responses: +Tool Queries and Responses: {responses} Conversation History (only if relevant): @@ -568,35 +283,52 @@ Chat: Title:""", # noqa: E501 ) -multi_tool_system_prompt = f"""You are an advanced AI assistant that helps orchestrate retrieval tools to answer user questions at Plane. +# TOOL_CALL_REASONING_REINFORCEMENT = """**FINAL 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., "To answer your question, I need to find all work items assigned to you. Let me query the database...") # noqa: E501 +# - AFTER: Summarize what you found and your next step (e.g., "Found 3 work items assigned to you. Now I'll provide you with the details...") +# - 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 details...") +# - Provide this in the content field surrounding your tool_calls""" # noqa: E501 + + +pai_ask_system_prompt = f"""You are an advanced AI assistant that helps answer user questions at Plane, a work management platform. +Your name is Plane AI (formerly, Plane Intelligence (Pi)) and your job is to provide a coherent and comprehensive answer given a user query utilizing the appropriate tools provided to you. Here is the context about Plane: {plane_context} -You will be provided with details of the selected tools and their corresponding queries in the below format: -Tool Name: xyz_tool; -Query: xyz_query -\n -Tool Name: abc_tool; -Query: abc_query +Plane AI has two modes: ASK and BUILD. Ask mode is designed for answering questions and retrieving information. Build mode is designed for modifying data on Plane. -Your goal is to determine the optimal SEQUENTIAL execution order for the selected tools to answer the user's query, and then execute them in that order. +**IMPORTANT - Current Mode: ASK (Answering Questions Only)** +You CANNOT modify Plane workspace data (e.g., Work Items, Projects, Pages). You can only provide answers by retrieving data. +However, you CAN format your answers using markdown (e.g., tabular format, lists, summaries) - this is NOT considered modifying Plane entities. + +**Disambiguation - "Table":** +- **Markdown Table**: A presentation format in your response. ✅ ALLOWED in ASK mode. +- **Plane Table View**: A specific layout/view in the Plane application. ❌ NOT ALLOWED (cannot create/modify views in ASK mode). +If the user asks to "create a table" of data/comparison, assume they mean a Markdown Table unless they explicitly refer to the "Table View" feature. + +If the user requests to create or modify actual Plane data (like "create a new project" or "add a work item"), politely inform them that you are in Ask mode and cannot modify data, for which the user needs to switch to BUILD mode. **Available Tools:** a) **Retrieval Tools:** - 1. generic_query_tool: For queries that are not related to Plane, its usage, or its internal data. This includes pleasantries. - 2. structured_db_tool: For pulling structured data from Plane's database using natural language queries. It is a text2sql agent. + 1. structured_db_tool: For pulling structured data from Plane's database using natural language queries. It is a text2sql tool. This includes queries about work-item assignments, states, and other structured attributes. Don't use this tool for semantic search. - 3. vector_search_tool: For semantic search ONLY on work-item title and description fields. + 2. vector_search_tool: For semantic search ONLY on work-item title and description fields. Not to be used for: - Comments - Updates - Activity streams - Any other structured data - 4. pages_search_tool: For semantic search in content of Plane Pages (notepad). + 3. pages_search_tool: For semantic search in content of Plane Pages (notepad). Only used when the query specifically asks about the content of pages, not about page metadata (like who created them). - 5. docs_search_tool: For searching Plane's official documentation. + 4. docs_search_tool: For searching Plane's official documentation. + **Always use this tool for:** + - "How to" questions related to Plane app and its features (e.g., "how to create a module?", "how do I set up cycles?") + - Setup/configuration questions (e.g., "how to configure project settings?") + **When NOT to use:** Questions about specific data in the user's workspace (use structured_db_tool or vector_search_tool or some other retrieval tool instead) b) **Entity Search Tools** (for disambiguation): search_user_by_name, search_workitem_by_name, search_project_by_name, search_module_by_name, search_cycle_by_name, search_current_cycle, list_recent_cycles, search_label_by_name, search_state_by_name, search_workitem_by_identifier, list_member_projects (active-only, membership-filtered projects) c) **fetch_cycle_details**: CRITICAL - Use this FIRST when user asks about cycle metrics, progress, or details. Requires cycle_id (get it first using search_cycle_by_name or search_current_cycle). @@ -642,144 +374,120 @@ Your goal is to determine the optimal SEQUENTIAL execution order for the selecte 5. If NO (query needs custom joins, complex filters, or cross-cycle analytics): use structured_db_tool - For questions that clearly need custom SQL (e.g., 'compare cycles', 'issues NOT in any cycle', 'velocity across all cycles'), use structured_db_tool directly -**Tool IO:** -- vector_search_tool (for work-items) accepts a text query and returns text results and a list of work-item IDs. -- pages_search_tool (for pages) accepts a text query and returns text results and a list of page IDs. -- fetch_cycle_details accepts cycle_id and optional facets/filters. Returns formatted cycle metrics (summary, breakdowns, burndown, etc). -- structured_db_tool accepts a natural language query and issue_ids/page_ids. Returns a formatted string with the query execution results. -- docs_search_tool returns a formatted string with the documentation search results. -- generic_query_tool is a fallback tool for any other queries. -- **Entity Search Tools**: Accept entity name, return entity details with ID. If multiple matches found, result includes "MULTIPLE MATCHES" text with candidate list. -- **ask_for_clarification**: Accepts reason, questions, and disambiguation_options. Returns JSON payload that pauses execution for user input. - -**Sequential Chaining Logic:** -- **Entity Resolution First**: If query mentions cycles/projects/users/etc, use entity search tools (search_current_cycle, search_cycle_by_name, etc) to get IDs -- **Specialized Tools Second**: Use specialized retrieval tools when available: - - When the query references "current cycle" or a specific cycle or mentions a cycle by name: - 1. Call search_current_cycle (or search_cycle_by_name) to resolve the cycle_id - 2. Call fetch_cycle_details with that cycle_id (adapt facets/filters to match the question) - - NEVER use structured_db_tool to answer cycle-specific metrics, progress, status, or issue listings if fetch_cycle_details can cover it. - - For semantic search → use vector_search_tool or pages_search_tool -- **Generic Database Third**: Only use structured_db_tool for queries that don't fit specialized tools (e.g., non-cycle analytics, custom joins outside fetch_cycle_details coverage) -- **Information Discovery**: Start with tools that find relevant content (vector/pages/docs) -- **Enrichment**: Follow with structured queries to get specific details about discovered items -- **Context Building**: Earlier tool outputs should inform later tool queries -- **Empty Results Handling**: If a tool returns no results, skip subsequent tools that depend on that data - -**ENTITY DISAMBIGUATION STRATEGY:** - -**CRITICAL: When dealing with entity references (users, work-items, projects, etc.), disambiguate them BEFORE using structured queries ONLY IF no resolved IDs are already provided:** - -1. **Detect Ambiguous References**: If your query mentions entity names (like "John", "project X", "bug Y") that could match multiple entities AND no resolved IDs (e.g., user_id, project_id, cycle_id) are provided -2. **Use Entity Search First**: Before the retrieval tools, call the appropriate search_*_by_name tool (only when IDs are absent) -3. **Handle Multiple Matches**: If entity search returns multiple results with "MULTIPLE MATCHES", call ask_for_clarification with: - - reason: "Multiple matches found for [entity_type] '[name]'" - - questions: ["Which [entity] did you mean?"] - - disambiguation_options: List the matches from the search result -4. **Handle Zero Matches**: If no entity found, call ask_for_clarification asking for more details -5. **Use Resolved IDs**: If resolved IDs are provided in the selected tools list (e.g., user_id in the router-synthesized query), SKIP entity search and proceed directly with retrieval tools -6. **INCORPORATE RESOLVED IDs IN FOLLOW-UP QUERIES (CRITICAL)**: After successfully resolving an entity using search tools, you MUST incorporate the resolved entity ID/IDs into subsequent tool queries. - -**Example Disambiguation Flow:** -- Query: "List work items assigned to Robert" -- Step 1: Call search_user_by_name("Robert") → Returns multiple users -- Step 2: Call ask_for_clarification with user options -- (System pauses for user input) - -**EXECUTION STRATEGY:** - -**Phase 1 - Initial Discovery (using the initial queries passed to you along with the selected tools):** -- **ENTITY DISAMBIGUATION FIRST**: If the query contains entity references, use entity search tools to resolve them before the main tools -- Use the EXACT query strings provided in the selected tools list for main tools **WITH ONE CRITICAL EXCEPTION**: - - **EXCEPTION: Entity ID Incorporation** - After successfully resolving entities (users, projects, cycles, modules, etc.) using search tools, you MUST replace the entity name/reference in the query with the resolved entity ID - - For example, if search_user_by_name returns user_id "abc-123", change "assigned to John" to "assigned to user with id: abc-123" - - This is NOT considered "modifying" the query - it's enriching it with resolved context -- Do not otherwise modify, rephrase, or optimize the provided tool queries -- Remember that these queries: - - are generated by me, not the user. - - They are designed to be as precise as possible. - - They are optimized for the specific tool's capabilities and limitations. - - Therefore, you should not modify them EXCEPT to incorporate resolved entity IDs. - - Don't pass text/string searches as part of structured_db_tool, especially if the text string is already assigned to a semantic search tool. -- Execute tools in logical order (disambiguation → discovery → enrichment) -- Empty results from any tool means you can skip to Phase 2. - -**Phase 2 - Refinement (IF needed):** -- ONLY if Phase 1 results are insufficient to answer the user's question -- You may then modify queries to get additional details -- Clearly indicate this is a refinement attempt - -**Example 1 - No Entity Resolution Needed:** -Given tools: [("vector_search_tool", "login issues"), ("structured_db_tool", "my tasks")] - -Phase 1: -- Call vector_search_tool(query="login issues") # EXACT string -- Call structured_db_tool(query="my tasks") # EXACT string - -**Example 2 - Entity Resolution Required:** -Given tools: [("structured_db_tool", "work items assigned to John")] - -Phase 1: -- Call search_user_by_name("John") # Returns user_id: "abc-123-def" -- Call structured_db_tool(query="work items assigned to user with id: abc-123-def") # Incorporated resolved ID - -Phase 2 (only if needed): -- If results insufficient, call tools with modified queries for more details - -Execute Phase 1 first with exact queries (incorporating any resolved entity IDs). Only proceed to Phase 2 if necessary. +**Tool Usage Guidelines:** **Query Formulation:** -- Use the exact queries as provided in the selected tools list, EXCEPT when incorporating resolved entity IDs (always replace entity names with resolved IDs). -- The structured_db_tool is a text2sql agent - it takes natural language input and converts it to SQL internally using Plane's database schema knowledge. Do NOT send SQL queries to this tool. -- Send natural language queries like "show me all high priority bugs assigned to John" or "count issues by state" to the structured_db_tool. -- **CRITICAL for structured_db_tool**: If you resolved entity IDs (from search_user_by_name, search_project_by_name, etc.), incorporate those IDs in the query. For example: "show me work items assigned to user with id: abc-123" instead of "assigned to John". -- Don't depend on the structured_db_tool to perform semantic search. Use the vector_search_tool for that. - - Only invoke vector_search_tool when the user specifies concrete content topics/keywords to match; do NOT trigger it for presentation-only instructions about transforming or displaying existing fields. -- If you collected issue_ids/page_ids from the semantic search, best to pass them in the IDs parameter of the structured_db_tool. Not in the query parameter. +- structured_db_tool: Send natural language queries (e.g., "show me all high priority bugs assigned to John"). It converts to SQL internally - do NOT send SQL. +- After resolving entity IDs: incorporate them in queries. Example: "work items assigned to user with id: abc-123" instead of "assigned to John". +- vector_search_tool: Use for semantic/content search on work-item titles and descriptions. Use structured_db_tool for filtering by metadata (assignees, states, dates). +- Pass semantic search results (issue_ids/page_ids) as IDs parameter to structured_db_tool, not in the query text. + +**Note:** Internal tool parameters use 'issue_ids' (matching database schema), but always say "work-item" when communicating with users. + +**Tool Selection Order:** +1. **Entity search first** if query mentions entity names (of any user, project, cycle, module, etc.) +2. **Choose primary retrieval tool:** + - Cycle metrics/progress → fetch_cycle_details (see cycle rules above) + - Semantic/content search → vector_search_tool or pages_search_tool + - Documentation questions → docs_search_tool + - Structured queries (assignments, states, counts) → structured_db_tool +3. **Enrichment** (if needed): Use additional tools to get more details +4. **Context building**: Use earlier tool outputs to inform later queries +5. **Skip dependent tools** if a tool returns no results + +**CONTEXT-PROVIDED ENTITY IDS:** +When the user refers to entities using contextual references ("this project", "this cycle", "me", "my"), check if the relevant UUID is already available in the provided context: +- "this project" / "the project" → use project_id from context if available +- "this cycle" / "current cycle" → use cycle_id from context if available (or call search_current_cycle if not in context) +- "me" / "my" / "I" → use user_id from context + +DO NOT call entity search tools (search_*_by_name) when the UUID is already available in context. Use the UUID directly in your tool calls. + +Entity search tools should ONLY be called when: +- The user mentions a specific NAME (e.g., "project called Mobile", "user named David", "cycle named Sprint 5", "Workite type Bug") +- The required UUID is NOT available in the provided context +- You need to disambiguate between multiple entities with similar names + +**ENTITY DISAMBIGUATION:** + +When query mentions entity NAMES without IDs and the UUID is NOT in context: +1. Call appropriate search_*_by_name tool first (e.g., search_user_by_name, search_project_by_name) +2. If search returns multiple matches: call ask_for_clarification with the disambiguation options +3. If search returns no matches (e.g., "No user found", "Not found", "No project found", etc.): + - **YOU MUST call ask_for_clarification** - DO NOT just report the error to the user + - Use reason: "No [entity_type] found matching '[search_term]'" + - Use questions: ["Could you provide more details or check the spelling?", "Is there an alternative name or identifier?"] + - Use category_hints: [entity_type] (e.g., ["users"], ["projects"], ["workitems"]) + - This is MANDATORY - never skip clarification when entity search fails +4. After resolving entities: incorporate resolved IDs in subsequent tool queries (e.g., "assigned to user with id: abc-123") + +When IDs already provided: Skip entity search and use IDs directly. + +Examples: + - Query "List work items assigned to Robert" + no user_id in context → search_user_by_name("Robert") → if multiple matches → ask_for_clarification + - Query "List my work items" + user_id in context → use user_id directly without calling entity search + - Query "is time tracking enabled in this project?" + project_id in context → use project_id directly without calling search_project_by_name + - Query "show me the Mobile project" + no project_id in context → search_project_by_name("Mobile") → use resolved project_id **Default Incomplete Work Scope:** -- When the user's phrasing implies items that should be worked on now or are pending (e.g., "to pick up", "open", "pending", "to-do", "missed to add", "should be in this cycle", "not yet scheduled"), and the user does not explicitly ask for completed/closed items, include an uncompleted-state filter in the structured_db_tool query: state bucket/group IN (backlog, unstarted, started) and exclude completed/cancelled. -- Prefer filtering by the canonical state bucket/group if available; otherwise, use state names as a fallback in a case-insensitive manner. -- Combine this with any other constraints (e.g., membership, priority, assignees, dates) required by the user intent. +For queries about pending/open work (unless explicitly asking for completed items): +- Filter by state groups: backlog, unstarted, started (exclude completed/cancelled) +- Use state group field when available; otherwise use state names (case-insensitive) +- Combine with other constraints (assignees, priority, dates) as needed **Priority Canonicalization:** -- Canonical priority values are: urgent, high, medium, low (case-insensitive). Do not invent other names like "highest". -- Map common synonyms to canonical values: "highest"/"critical"/"blocker"/"p0" → urgent; "very high"/"p1" → high; "normal"/"standard" → medium; "lowest" → low. -- When formulating structured_db_tool queries, use canonical values for priority filters and canonical urgent → high → medium → low order when sorting. -- For phrases like "highest priority" or "top priority" without an explicit priority name, do NOT equate to "urgent". Order by canonical priority and return the top item(s) without adding an equality filter unless the user explicitly says "urgent"/"high"/etc. +- Canonical values: urgent, high, medium, low (case-insensitive only) +- Map synonyms: "highest"/"critical"/"p0" → urgent, "very high"/"p1" → high, "normal" → medium, "lowest" → low +- Use canonical values in queries and for sorting +- For "top priority" / "highest priority": sort by priority and return top items (don't filter to urgent unless explicitly stated) + +**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 +| - INSTEAD: Use ONE `structured_db_tool` call WITHOUT `project_id` parameter +| - Query will automatically scope to workspace via `workspace_id` (already in context) +| - Example WRONG: `list_member_projects` + 7x `structured_db_tool` (each with different project_id) +| - Example RIGHT: Single `structured_db_tool`: "list all workitems prioritized as high" +|- For project-specific queries, include the specific `project_id` + +**Project Features:** +- Cycles, modules, pages, workitem types, views, intake, and time tracking are project-level features that are enabled/disabled on a per-project basis. +- You can use: + - `projects_retrieve` tool to get details about whether the project features are enabled or disabled. **Important:** -Only use the tools that were provided in the selected tools list, in your recommended execution order. +Only use the tools that were provided in the given tools list, in your recommended execution order. Analyze the user's query and determine the most efficient sequential order that builds context progressively. Always call tools in the logical order needed to answer the question completely. + +When you have the complete answer, answer the user's question in a coherent and comprehensive manner. +Use the user's first name naturally in conversation when it feels appropriate. + +Rules to follow while answering the user's question: +1. Ensure your answer directly addresses the user query. +3. **Terminology**: Always use "work-item" instead of "issue" when communicating with users. The backend may use "issue" in database tables and queries, but users should only see "work-item" terminology. +4. **Unique Keys**: Refer to work-item identifiers (like PAI-123, MOB-45) as "unique key" instead of "Issue ID" in user-facing responses. +5. Suppress the UUIDs (like User ID, Issue ID, Page ID, Project ID (The UUID of the project. Not the identifier), Workspace ID, etc) in your response. These are PII data. Never show them. + And don't mention the suppression in your response. + However, remember that when the user mentions 'issue ID' or 'issue identifier' he/she mean to refer to the issue identifier which is not UUIDs but the unique key like PAI-123, MOB-45. + For example, if the user asks "What is the issue ID of the issue 'Support for Custom Fields'?", and you are provided with the unique key 'PAI-123' in the responses by the tools, your response should be "The issue ID of the issue 'Support for Custom Fields' is PAI-123". + You can reveal the name of the user to him/her in your response, if requested. + In case of project, feel free to show the project identifier, but never show the UUID, even if the project_id (UUID) is mentioned by the user in the query. Only show the project identifier in your response. +6. If there is no data related to the user's query, just convey this. Don't hallucinate. + - IMPORTANT: Never mention internal details like "SQL", "query execution", or tool names when reporting no data. + - Phrase it in a user-friendly way tailored to the question, e.g., "No recent comments found on your work-items." + - Optionally, suggest one concise next step (e.g., broaden filters, time range) if helpful. +7. Never provide sensitive information: passwords, API keys, table names, schema details, or SQL queries. +8. If structured_db_tool has been deployed: + - List all items retrieved with relevant details (excluding UUIDs as per Rule 5) + - DO NOT re-filter results (already filtered by SQL query) + - Never reveal the SQL query to the end user +9. URL Embeddings: Create clickable links for entities with URLs in the Entity URLs section: + - Work-items: Entity Name [Unique Key](URL). Example: Support for Custom Fields [PAI-123](https://xyz.com/abc/123/) + - Other entities: Entity Name [view](URL). Example: Meeting Notes [view](https://xyz.com/abc/123/) + - In tables with separate columns: make the unique key/identifier column clickable [PAI-123](URL) + - Skip linking if entity not in Entity URLs section +10. When using tables to present data: Never include UUIDs (must be suppressed), and apply URL embedding rules from Rule 9. """ # noqa: E501 - - -# In pi/services/chat/prompts.py, add this constant: -RETRIEVAL_TOOL_DESCRIPTIONS = """ -**Retrieval Tool Capabilities:** - -1. **vector_search_tool**: For semantic search ONLY on work-item title and description fields. - - USE FOR: Finding work items by content, topics, keywords, concepts - - NOT FOR: Comments, updates, activity streams, state changes, metadata queries, presentation/output-formatting instructions - - RETURNS: Text results and a list of work-item IDs - -2. **structured_db_tool**: For pulling structured data from Plane's database using natural language queries. - - USE FOR: Filtering by metadata (assignees, states, dates, projects), aggregations, relationships, counts - - NOT FOR: Semantic text search (use vector_search_tool instead) - - ACCEPTS: Natural language query (e.g., "show me all high priority bugs assigned to John") and optional issue_ids/page_ids from prior searches - - NOTE: This tool is a text2sql agent - it converts your natural language to SQL internally using Plane's database schema knowledge - -3. **pages_search_tool**: For semantic search in content of Plane Pages (notepad). - - USE FOR: Finding pages by content, topics, concepts - - NOT FOR: Page metadata queries (who created, when created) - - RETURNS: Text results and a list of page IDs - -4. **docs_search_tool**: For searching Plane's official documentation. - - USE FOR: Finding documentation by topics, features, how-to guides - - RETURNS: Formatted documentation search results - -5. **generic_query_tool**: Fallback for non-Plane queries and pleasantries. -""" diff --git a/apps/pi/pi/services/chat/templates.py b/apps/pi/pi/services/chat/templates.py index 7db034e40a..4b2def168b 100644 --- a/apps/pi/pi/services/chat/templates.py +++ b/apps/pi/pi/services/chat/templates.py @@ -50,8 +50,8 @@ def preset_question_flow(question: str) -> List[Dict[str, Any]]: Returns a list of flow steps that define the complete processing pipeline. Each step contains: - - step_type: The type of step (routing, agent_execution, etc.) - - agents: List of agents to execute with their queries and parameters + - step_type: The type of step (routing, tool_execution, etc.) + - agents: List of tools to execute with their queries and parameters (field name kept for backward compatibility) - skip_routing: Whether to skip the normal routing process - reasoning_messages: Messages to show during processing """ @@ -67,7 +67,7 @@ def preset_question_flow(question: str) -> List[Dict[str, Any]]: ], "agents": [ { - "agent": "plane_structured_database_agent", + "agent": "structured_db_tool", "query": "Show me my urgent work items that are still pending", "sql_query": """SELECT issues.id::text AS issues_id, @@ -99,7 +99,7 @@ LIMIT 20;""", ], "agents": [ { - "agent": "plane_structured_database_agent", + "agent": "structured_db_tool", "query": "What work items are assigned to me and not yet completed, with start and end dates, priorities?", "sql_query": """SELECT issues.id::text AS issues_id, @@ -135,7 +135,7 @@ LIMIT 20;""", ], "agents": [ { - "agent": "plane_structured_database_agent", + "agent": "structured_db_tool", "query": "Show me my overdue work items", "sql_query": """SELECT issues.id::text AS issues_id, @@ -148,7 +148,7 @@ LEFT JOIN states ON issues.state_id = states.id WHERE issue_assignees.assignee_id = $1 AND issues.target_date IS NOT NULL - AND issues.target_date < CURRENT_DATE + AND issues.target_date::date < CURRENT_DATE AND (states.group != 'completed' AND states.group != 'cancelled') AND issues.deleted_at IS NULL AND issue_assignees.deleted_at IS NULL @@ -170,7 +170,7 @@ LIMIT 20;""", ], "agents": [ { - "agent": "plane_structured_database_agent", + "agent": "structured_db_tool", "query": "What did I complete this week?", "sql_query": """SELECT issues.id::text AS issues_id, @@ -207,7 +207,7 @@ LIMIT 20;""", ], "agents": [ { - "agent": "plane_structured_database_agent", + "agent": "structured_db_tool", "query": "Show me work items I'm working on currently", "sql_query": """SELECT issues.id::text AS issues_id, @@ -242,7 +242,7 @@ LIMIT 20;""", ], "agents": [ { - "agent": "plane_structured_database_agent", + "agent": "structured_db_tool", "query": "What work items are assigned to me that are blocked, and which ones are blocking them?", "sql_query": """SELECT i.id::text AS issues_id, @@ -281,21 +281,33 @@ LIMIT 20;""", ], "agents": [ { - "agent": "plane_structured_database_agent", + "agent": "structured_db_tool", "query": "Who commented on my work items recently?", "sql_query": """SELECT - users.id::text AS users_id, - users.first_name, - users.last_name, - MAX(issue_comments.created_at) AS most_recent_comment_date -FROM issue_assignees -JOIN issues ON issue_assignees.issue_id = issues.id -JOIN issue_comments ON issue_comments.issue_id = issues.id -JOIN users ON users.id = issue_comments.created_by_id -WHERE issue_assignees.assignee_id = $1 - AND issue_comments.deleted_at IS NULL - AND issue_assignees.deleted_at IS NULL -GROUP BY users.id, users.first_name, users.last_name + users_id, + first_name, + last_name, + most_recent_comment_date, + issues_id, + issue_name +FROM ( + SELECT DISTINCT ON (users.id) + users.id::text AS users_id, + users.first_name, + users.last_name, + issue_comments.created_at AS most_recent_comment_date, + issues.id::text AS issues_id, + issues.name AS issue_name + FROM issue_assignees + JOIN issues ON issue_assignees.issue_id = issues.id + JOIN issue_comments ON issue_comments.issue_id = issues.id + JOIN users ON users.id = issue_comments.created_by_id + WHERE issue_assignees.assignee_id = $1 + AND users.id <> $1 + AND issue_comments.deleted_at IS NULL + AND issue_assignees.deleted_at IS NULL + ORDER BY users.id, issue_comments.created_at DESC +) t ORDER BY most_recent_comment_date DESC LIMIT 20;""", "tables": ["issues", "users", "issue_assignees", "issue_comments"], @@ -315,7 +327,7 @@ LIMIT 20;""", ], "agents": [ { - "agent": "plane_structured_database_agent", + "agent": "structured_db_tool", "query": "What changed in my pending work items today?", "sql_query": """SELECT issues.id::text AS issues_id, @@ -357,7 +369,7 @@ LIMIT 20;""", ], "agents": [ { - "agent": "plane_structured_database_agent", + "agent": "structured_db_tool", "query": "Show me recent activity on my pending work items", "sql_query": """SELECT ia.id::text AS issue_activities_id, @@ -406,7 +418,7 @@ LIMIT 20;""", ], "agents": [ { - "agent": "plane_structured_database_agent", + "agent": "structured_db_tool", "query": "What's the progress of current active cycles?", "sql_query": """SELECT cycles.id::text AS cycles_id, @@ -463,7 +475,7 @@ ORDER BY cycles.start_date DESC NULLS LAST, cycles.name ASC;""", # ] # }, # { - # "step_type": "agent_execution", + # "step_type": "tool_execution", # "reasoning_messages": [ # "🔧 Step 2: Retrieving detailed updates for the work item" # ], @@ -497,7 +509,7 @@ ORDER BY cycles.start_date DESC NULLS LAST, cycles.name ASC;""", # ] # }, # { - # "step_type": "agent_execution", + # "step_type": "tool_execution", # "reasoning_messages": [ # "🔧 Step 2: Retrieving detailed updates for each work item" # ], diff --git a/apps/pi/pi/services/chat/utils.py b/apps/pi/pi/services/chat/utils.py index a0921b6628..013295faf8 100644 --- a/apps/pi/pi/services/chat/utils.py +++ b/apps/pi/pi/services/chat/utils.py @@ -1,4 +1,5 @@ import json +import random import re import uuid from typing import Any @@ -39,8 +40,6 @@ def mask_uuids_in_text(text: str) -> str: Returns: Cleaned text with UUIDs masked and context removed """ - import re - uuid_pattern = r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b" def replace_uuid(match): @@ -150,6 +149,22 @@ class StandardAgentResponse: response_lower = response_text.lower() return any(pattern.lower() in response_lower for pattern in error_patterns) + @staticmethod + def format_response_with_entity_urls(response: Union[str, Dict[str, Any]]) -> str: + """Format a response into a string with entity URLs""" + if isinstance(response, dict): + entity_urls = response.get("entity_urls") or [] + url_info = "" + if entity_urls: + url_info = "\n\n**Entity URLs Available:**\n" + for url in entity_urls: + url_info += f"- {url.get("type", "").title()}: {url.get("name")} - URL: {url.get("url")}\n" + if url.get("type") == "issue": + url_info += f"Issue Unique Key: {url.get("issue_identifier")}\n" + formatted_response = response.get("results", "") + url_info + return formatted_response + return str(response) + @staticmethod def format_responses(responses, chat_id, query_flow_store): formatted_responses = [] @@ -196,6 +211,89 @@ class StandardAgentResponse: return {"formatted": formatted_responses_str, "has_errors": has_errors} +def reasoning_header_factory(stage: str, tool_name: str, tool_query: str) -> str: + """Factory function to create a reasoning header for a given stage""" + stage_dict = { + "build_beginning": [ + "🧠 Assembling the action sequence...\n\n", + "🤔 Planning next steps...\n\n", + "🧠 Brainstorming the best approach...\n\n", + "🛤️ Mapping out the action path...\n\n", + "🚧 Constructing your request workflow...\n\n", + "🔍 Evaluating available options...\n\n", + "⏳ Orchestrating the next steps...\n\n", + ], + "selected_action_categories": [ + "🧭 Identified planning focus\n\n", + "🔍 Evaluated relevant areas\n\n", + ], + "actions_clarification_followup": [ + "🧠 Continuing with your request...\n\n", + ], + "planner_tool_selection_calling": [ + "🧠 Zeroing in on the best tool...\n\n", + "🔍 Narrowing down the next moves...\n\n", + "🧭 Steps to take next...\n\n", + ], + "planner_tool_selection": [ + "💭 Contemplating...\n\n", + ], + "planner_tool_selection_final": [ + f"🤖 {tool_name} ({tool_query})\n\n" if tool_query else f"🤖 {tool_name}\n\n", + ], + "retrieval_tool_execution": [ + f"🔧 Executing: {tool_name}: {tool_query}\n\n", + f"Calling {tool_name} for {tool_query}\n\n", + ], + "retrieval_tool_execution_message": [ + f"✅Tool {tool_name} returned its output...\n\n", + f"I received {tool_name}'s result..\n\n", + ], + "tool_complete": [ + f"✅ {tool_name} execution completed\n\n", + f"{tool_name} is executed successfully\n\n", + ], + "tool_error": [ + f"⚠️ Error executing {tool_name}\n\n", + ], + "tool_unavailable": [ + "⚠️ A required capability was unavailable.\n\n", + ], + "ask_mode_clarification_followup": [ + "🤖 Processing your clarification...\n\n", + ], + "ask_mode_beginning": [ + "🤖 Retrieving information...\n\n", + "🤖 Processing your request...\n\n", + "🧠 Planning next steps...\n\n", + "🤔 Planning next steps...\n\n", + "🧠 Brainstorming the best approach...\n\n", + ], + "ask_mode_analyzing_results": [ + "🤖 Analyzing results...\n\n", + "🧠 Reviewing tool outputs...\n\n", + ], + "ask_preset_reasoning": [ + "🧭 Using optimized path...\n\n", + "💡 Applying optimized insights...\n\n", + "🧠 Following optimized guidance...\n\n", + ], + "final_response": ["📝 Generating final response...\n\n"], + } + stage_list = stage_dict.get(stage, "🧠 {stage}\n\n") + return random.choice(stage_list) + + +def reasoning_dict_maker(stage: str, tool_name: str, tool_query: str, content: str) -> dict: + """Factory function to create a reasoning dictionary for a given stage""" + return { + "chunk_type": "reasoning", + "header": mask_uuids_in_text(reasoning_header_factory(stage, tool_name, tool_query)), + "content": mask_uuids_in_text(content), + "stage": stage, + } + + def format_conversation_history(conversation_history): return "\n".join([f"{m.type}: {m.content}" for m in conversation_history]) @@ -257,12 +355,13 @@ async def process_conv_history(conv_history: list[dict[str, Any]], db: AsyncSess # Extract and include action context if available action_context = "" execution_status = qa_pair.get("execution_status", {}) - if execution_status.get("has_planned_actions") and execution_status.get("actions"): + # Fix: Remove has_planned_actions gate - just check if actions exist + if execution_status.get("actions"): action_context += "\n**Action Context:**\n" # Include planning context from actions for action in execution_status.get("actions", []): - tool_name = action.get("tool") + action_type = action.get("action") success = action.get("success") if success and action.get("entity"): @@ -271,11 +370,11 @@ async def process_conv_history(conv_history: list[dict[str, Any]], db: AsyncSess entity_name = entity.get("entity_name", "") entity_id = entity.get("entity_id", "") - action_context += f"- Action: {tool_name} → Successfully created/updated {entity_type} '{entity_name}' (ID: {entity_id})\n" + action_context += f"- Action: {action_type} → Successfully created/updated {entity_type} '{entity_name}' (ID: {entity_id})\n" elif success: - action_context += f"- Action: {tool_name} → Executed successfully\n" + action_context += f"- Action: {action_type} → Executed successfully\n" else: - action_context += f"- Action: {tool_name} → Failed to execute\n" + action_context += f"- Action: {action_type} → Failed to execute\n" # Check if we can extract planning context from the qa_pair structure # This would be available when the enhanced execution_data includes planning_context @@ -441,7 +540,6 @@ async def initialize_new_chat( chat_exists = await check_if_chat_exists(final_chat_id, db) if chat_exists: return {"success": False, "message": "Chat ID already exists", "error_code": "CHAT_EXISTS"} - log.info(f"ChatID: {final_chat_id} - Initializing chat context to db: is_project_chat={is_project_chat}") # Create the chat record without workspace details (will be backfilled later) chat_result = await upsert_chat( chat_id=final_chat_id, diff --git a/apps/pi/pi/services/dupes/dupes.py b/apps/pi/pi/services/dupes/dupes.py index af23638423..e897a60198 100644 --- a/apps/pi/pi/services/dupes/dupes.py +++ b/apps/pi/pi/services/dupes/dupes.py @@ -1,7 +1,7 @@ import time -from langchain.schema import HumanMessage -from langchain.schema import SystemMessage +from langchain_core.messages import HumanMessage +from langchain_core.messages import SystemMessage from pi import logger from pi import settings @@ -83,7 +83,7 @@ async def identify_duplicates_with_llm(query_title: str, query_description: str, # Create the dupes LLM with structured output (include_raw=True to preserve token usage) dupes_llm = get_dupes_llm() - dupes_structured_llm = dupes_llm.with_structured_output(DuplicateIdentificationResponse, include_raw=True) # type: ignore[arg-type] + dupes_structured_llm = dupes_llm.with_structured_output(DuplicateIdentificationResponse, include_raw=True, method="json_schema") # type: ignore[arg-type] # Format candidates for LLM input as a clean numbered list candidates_text = "" diff --git a/apps/pi/pi/services/llm/llms.py b/apps/pi/pi/services/llm/llms.py index 0800da0277..d42b5221c4 100644 --- a/apps/pi/pi/services/llm/llms.py +++ b/apps/pi/pi/services/llm/llms.py @@ -11,7 +11,7 @@ from typing import Iterator from typing import Optional from uuid import UUID -from langchain.schema.language_model import BaseLanguageModel +from langchain_core.language_models.base import BaseLanguageModel from langchain_core.runnables import Runnable from langchain_core.runnables import RunnableConfig from langchain_openai import ChatOpenAI @@ -592,9 +592,53 @@ def get_dupes_llm() -> BaseLanguageModel: return create_openai_llm(fallback_config) -# Global instances for backward compatibility -llm = LLMFactory.get_default_llm() -stream_llm = LLMFactory.get_stream_llm() -decomposer_llm = LLMFactory.get_decomposer_llm() -fast_llm = LLMFactory.get_fast_llm(streaming=False) -fast_llm_stream = LLMFactory.get_fast_llm(streaming=True) +class LazyLLM(Runnable): + """Lazy initialization proxy for LLMs to avoid shared SSL contexts in forked processes.""" + + def __init__(self, factory_func: Any): + self._factory_func = factory_func + self._proxy_target: Any = None + + @property + def _target(self) -> Any: + if self._proxy_target is None: + self._proxy_target = self._factory_func() + return self._proxy_target + + def invoke(self, input: Any, config: Optional[RunnableConfig] = None, **kwargs: Any) -> Any: # noqa: A002 + return self._target.invoke(input, config, **kwargs) + + async def ainvoke(self, input: Any, config: Optional[RunnableConfig] = None, **kwargs: Any) -> Any: # noqa: A002 + return await self._target.ainvoke(input, config, **kwargs) + + def stream(self, input: Any, config: Optional[RunnableConfig] = None, **kwargs: Any) -> Iterator[Any]: # noqa: A002 + return self._target.stream(input, config, **kwargs) + + async def astream(self, input: Any, config: Optional[RunnableConfig] = None, **kwargs: Any) -> AsyncIterator[Any]: # noqa: A002 + async for chunk in self._target.astream(input, config, **kwargs): + yield chunk + + def bind_tools(self, *args: Any, **kwargs: Any) -> Any: + return self._target.bind_tools(*args, **kwargs) + + def with_structured_output(self, *args: Any, **kwargs: Any) -> Any: + return self._target.with_structured_output(*args, **kwargs) + + def __getattr__(self, name: str) -> Any: + return getattr(self._target, name) + + @property + def InputType(self) -> Any: + return self._target.InputType + + @property + def OutputType(self) -> Any: + return self._target.OutputType + + +# Global instances for backward compatibility - using LazyLLM to prevent fork issues +llm = LazyLLM(LLMFactory.get_default_llm) +stream_llm = LazyLLM(LLMFactory.get_stream_llm) +decomposer_llm = LazyLLM(LLMFactory.get_decomposer_llm) +fast_llm = LazyLLM(lambda: LLMFactory.get_fast_llm(streaming=False)) +fast_llm_stream = LazyLLM(lambda: LLMFactory.get_fast_llm(streaming=True)) diff --git a/apps/pi/pi/services/retrievers/docs_search.py b/apps/pi/pi/services/retrievers/docs_search.py index cb9a5a83d5..b9d593d4fb 100644 --- a/apps/pi/pi/services/retrievers/docs_search.py +++ b/apps/pi/pi/services/retrievers/docs_search.py @@ -1,5 +1,5 @@ -from langchain.callbacks.manager import AsyncCallbackManagerForRetrieverRun -from langchain.callbacks.manager import CallbackManagerForRetrieverRun +from langchain_core.callbacks.manager import AsyncCallbackManagerForRetrieverRun +from langchain_core.callbacks.manager import CallbackManagerForRetrieverRun from langchain_core.documents import Document from langchain_core.retrievers import BaseRetriever diff --git a/apps/pi/pi/services/retrievers/issue_search.py b/apps/pi/pi/services/retrievers/issue_search.py index 65fec4ba61..b37e7dcbe8 100644 --- a/apps/pi/pi/services/retrievers/issue_search.py +++ b/apps/pi/pi/services/retrievers/issue_search.py @@ -1,7 +1,7 @@ import asyncio -from langchain.callbacks.manager import AsyncCallbackManagerForRetrieverRun -from langchain.callbacks.manager import CallbackManagerForRetrieverRun +from langchain_core.callbacks.manager import AsyncCallbackManagerForRetrieverRun +from langchain_core.callbacks.manager import CallbackManagerForRetrieverRun from langchain_core.documents import Document from langchain_core.retrievers import BaseRetriever @@ -96,6 +96,8 @@ class IssueRetriever(BaseRetriever): Document( page_content=description, metadata={ + # Provide both for compatibility with downstream formatters + "title": title, "name": title, "relevance": round(hit.get("Score", 0), 3), "issue_id": issue_id, diff --git a/apps/pi/pi/services/retrievers/pages_search.py b/apps/pi/pi/services/retrievers/pages_search.py index 157f05d9d4..ed251dfae6 100644 --- a/apps/pi/pi/services/retrievers/pages_search.py +++ b/apps/pi/pi/services/retrievers/pages_search.py @@ -1,7 +1,7 @@ from typing import List -from langchain.callbacks.manager import AsyncCallbackManagerForRetrieverRun -from langchain.callbacks.manager import CallbackManagerForRetrieverRun +from langchain_core.callbacks.manager import AsyncCallbackManagerForRetrieverRun +from langchain_core.callbacks.manager import CallbackManagerForRetrieverRun from langchain_core.documents import Document from langchain_core.retrievers import BaseRetriever @@ -76,7 +76,8 @@ class PageChunkRetriever(BaseRetriever): Document( page_content=description, metadata={ - "page_title": title, + # Use 'name' to match downstream formatter + "name": title, "relevance": round(hit.get("Score", 0), 3), "page_id": issue_id, }, diff --git a/apps/pi/pi/services/retrievers/pg_store/action_artifact.py b/apps/pi/pi/services/retrievers/pg_store/action_artifact.py index 23ec791a5e..e5fc7ddbbc 100644 --- a/apps/pi/pi/services/retrievers/pg_store/action_artifact.py +++ b/apps/pi/pi/services/retrievers/pg_store/action_artifact.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Any from typing import Dict from typing import List @@ -12,6 +13,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from pi import logger from pi.app.models import ActionArtifact from pi.app.models.action_artifact import ActionArtifactVersion +from pi.app.models.enums import ExecutionStatus from pi.app.models.enums import FlowStepType from pi.app.models.message import MessageFlowStep @@ -35,7 +37,6 @@ async def get_action_artifacts_by_ids( .where(ActionArtifact.deleted_at.is_(None)) # type: ignore[union-attr,arg-type] .order_by(desc(ActionArtifact.created_at)) # type: ignore[union-attr,arg-type] ) - result = await db.execute(stmt) artifacts = list(result.scalars().all()) @@ -148,19 +149,27 @@ async def create_action_artifact( async def update_action_artifact_execution_status( db: AsyncSession, artifact_id: UUID4, + message_id: UUID4, + chat_id: UUID4, is_executed: bool, success: bool, entity_id: Optional[UUID4] = None, entity_info: Optional[Dict[str, Any]] = None, + execution_result: Optional[str] = None, + executed_at: Optional[str] = None, ) -> Dict[str, Any]: """ - Updates the execution status of an action artifact. + Updates the execution status of an action artifact and corresponding MessageFlowStep. Args: db: Database session artifact_id: ID of the artifact to update is_executed: New execution status + success: Whether the execution succeeded entity_id: Optional entity ID to set (if entity was created) + entity_info: Optional entity info dict (entity_url, entity_name, etc.) + execution_result: Optional execution result message + executed_at: Optional execution timestamp (ISO format string) Returns: A dictionary with operation status and the artifact object or error details. @@ -194,8 +203,62 @@ async def update_action_artifact_execution_status( # Don't fail if data shaping has issues; persistence of status is primary pass - # Add and commit + # Add artifact to session db.add(artifact) + + # Also update MessageFlowStep if artifact has a message_id + if message_id: + try: + # Find the MessageFlowStep for this artifact + flow_step_stmt = ( + select(MessageFlowStep) + .where(MessageFlowStep.chat_id == chat_id) # type: ignore[arg-type] + .where(MessageFlowStep.message_id == message_id) # type: ignore[arg-type] + .where(MessageFlowStep.execution_data.op("->>")("artifact_id") == str(artifact_id)) # type: ignore[union-attr,arg-type] + .where(MessageFlowStep.is_planned == True) # type: ignore[arg-type] # noqa: E712 + ) + flow_step_result = await db.execute(flow_step_stmt) + flow_step = flow_step_result.scalar_one_or_none() + + if flow_step: + # Mark as executed + flow_step.is_executed = is_executed + + # Set execution success status + if success: + flow_step.execution_success = ExecutionStatus.SUCCESS + flow_step.execution_error = None + else: + flow_step.execution_success = ExecutionStatus.FAILED + error_msg = ( + execution_result + if execution_result and "error" in execution_result.lower() + else f"Execution failed: {execution_result or "Unknown error"}" + ) + flow_step.execution_error = error_msg + + # Update execution_data + current_execution_data = flow_step.execution_data or {} + updated_execution_data = { + **current_execution_data, + "executed_at": executed_at or datetime.utcnow().isoformat(), + "execution_result": execution_result or "", + "business_success": success, + } + + if entity_info: + updated_execution_data["entity_info"] = entity_info + + flow_step.execution_data = updated_execution_data + attributes.flag_modified(flow_step, "execution_data") + db.add(flow_step) + else: + log.warning(f"MessageFlowStep not found for artifact {artifact_id} and message {message_id}") + except Exception as flow_step_error: + log.error(f"Error updating MessageFlowStep for artifact {artifact_id}: {flow_step_error}") + # Don't fail the main execution if flow step update fails + + # Commit all changes await db.commit() await db.refresh(artifact) @@ -528,7 +591,7 @@ async def add_query_to_artifact(db: AsyncSession, artifact_id: UUID4, message_id select(MessageFlowStep) .where( MessageFlowStep.step_type == "ARTIFACT_CHAT", # type: ignore[union-attr,arg-type] - MessageFlowStep.execution_data.op("->>")('"artifact_id"') == str(artifact_id), # type: ignore[union-attr,arg-type] + MessageFlowStep.execution_data.op("->>")("artifact_id") == str(artifact_id), # type: ignore[union-attr,arg-type] MessageFlowStep.message_id == message_id, # type: ignore[union-attr,arg-type] ) .order_by(desc(MessageFlowStep.created_at)) # type: ignore[union-attr,arg-type] @@ -576,7 +639,7 @@ async def get_artifact_prompt_history_from_flow_steps(db: AsyncSession, artifact try: conditions = [ MessageFlowStep.step_type == FlowStepType.ARTIFACT_CHAT, # type: ignore[union-attr,arg-type] - MessageFlowStep.execution_data.op("->>")('"artifact_id"') == str(artifact_id), # type: ignore[union-attr,arg-type] + MessageFlowStep.execution_data.op("->>")("artifact_id") == str(artifact_id), # type: ignore[union-attr,arg-type] MessageFlowStep.message_id != message_id, # type: ignore[union-attr,arg-type] ] diff --git a/apps/pi/pi/services/retrievers/pg_store/chat.py b/apps/pi/pi/services/retrievers/pg_store/chat.py index 270f67351f..92e3f6a1fd 100644 --- a/apps/pi/pi/services/retrievers/pg_store/chat.py +++ b/apps/pi/pi/services/retrievers/pg_store/chat.py @@ -3,6 +3,7 @@ import uuid from typing import Any from typing import Dict from typing import List +from typing import Literal from typing import Optional from typing import Tuple from typing import Union @@ -14,11 +15,13 @@ 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.app.models import Chat from pi.app.models import Message from pi.app.models import MessageFlowStep from pi.app.models import UserChatPreference from pi.app.models.enums import ExecutionStatus +from pi.app.models.enums import FocusEntityType from pi.app.models.enums import UserTypeChoices from pi.app.models.message_attachment import MessageAttachment from pi.app.schemas.chat import PaginationResponse @@ -27,6 +30,8 @@ from pi.app.utils.attachments import get_presigned_url_preview from pi.app.utils.pagination import apply_cursor_pagination from pi.app.utils.pagination import check_pagination_bounds from pi.app.utils.pagination import create_pagination_response +from pi.services.chat.helpers.tool_utils import format_tool_message_for_display +from pi.services.chat.utils import mask_uuids_in_text from pi.services.query_utils import parse_query log = logger.getChild(__name__) @@ -35,10 +40,10 @@ log = logger.getChild(__name__) internal_reasoning_format_dict = { "rewrite": "Rewritten Query", "routing": "Routing", - "tool": "Selected Agent", - "PLANE_STRUCTURED_DATABASE_AGENT": "Plane Structured Database Agent", - "PLANE_VECTOR_SEARCH_AGENT": "Plane Vector Search Agent", - "PLANE_PAGES_AGENT": "Plane Pages Search Agent", + "tool": "Selected Tool", + "structured_db_tool": "Structured DB Tool", + "vector_search_tool": "Vector Search Tool", + "pages_search_tool": "Pages Search Tool", } @@ -502,6 +507,171 @@ async def retrieve_chat_history( "execution_status": execution_status_info, } + # For external responses, augment reasoning with cleaned tool results from flow steps + if not pi_internal: + try: + # Collect relevant flow steps for this message, in order + message_specific_steps = [step for step in message_flow_steps if step.message_id == user_message.id] + message_specific_steps.sort(key=lambda x: x.step_order or 0) + + cleaned_blocks: List[str] = [] + for step in message_specific_steps: + # Only include tool steps; skip metadata/internal-only tools + if getattr(step, "step_type", None) != "tool": + continue + tool_name = getattr(step, "tool_name", None) + if tool_name in ( + "tool_orchestration_context", + "llm_reasoning", + "get_available_plane_actions", + "planner_summary", + ): + continue + # Skip planned actions in external reasoning (only show retrieval outputs) + if getattr(step, "is_planned", False): + continue + # Include retrieval steps even if execution_success wasn't explicitly set, + # as ask-mode persistence used to leave it as PENDING. Prefer executed or explicit success. + if ( + not getattr(step, "is_executed", False) + and getattr(step, "execution_success", None) != ExecutionStatus.SUCCESS + ): + continue + + # SPECIAL FILTERS: + # For structured_db_tool, hide intermediate steps (table selection, SQL generation, final_query planning) + if isinstance(tool_name, str) and tool_name.startswith("structured_db_tool_"): + # Only the core 'structured_db_tool' should be shown in external reasoning + continue + + # Extract raw content and format for display + exec_data = getattr(step, "execution_data", {}) or {} + raw_str: str = "" + if isinstance(exec_data, dict): + if exec_data.get("execution_result"): + raw_str = str(exec_data.get("execution_result")) + elif exec_data.get("retrieval_result"): + raw_str = str(exec_data.get("retrieval_result")) + elif exec_data.get("structured_result") is not None: + try: + raw_str = json.dumps(exec_data.get("structured_result"), default=str) + except Exception: + raw_str = str(exec_data.get("structured_result")) + if not raw_str: + raw_content = parse_flow_step_content(step.content or "") + try: + if isinstance(raw_content, (dict, list)): + raw_str = json.dumps(raw_content, default=str) + else: + raw_str = str(raw_content) + except Exception: + raw_str = str(raw_content) + + # Tool-specific external rendering + tname = (tool_name or "").strip().lower() + if tname == "structured_db_tool": + try: + # Prefer structured_result.results if available + results_obj = None + sql_query_str = None + if isinstance(exec_data, dict): + sr = exec_data.get("structured_result") + if isinstance(sr, dict): + results_obj = sr.get("results") + sql_query_str = sr.get("sql_query") + if results_obj is None and raw_str: + # Try to parse raw_str JSON and extract 'results' + try: + parsed = json.loads(raw_str) + if isinstance(parsed, dict): + results_obj = parsed.get("results") + if not sql_query_str: + sql_query_str = parsed.get("sql_query") + except Exception: + results_obj = None + + # Fallback: if still missing, try step.content JSON + if results_obj is None: + content_parsed = parse_flow_step_content(step.content or "") + if isinstance(content_parsed, dict): + results_obj = content_parsed.get("results") + if not sql_query_str: + sql_query_str = content_parsed.get("sql_query") + + # Build display exactly as live stream intent: success prelude + rows + prelude = "✅ Database querying execution completed" + rows_text = "" + if results_obj is not None: + # Use bullet points formatter (async) + rows_text = await format_as_bullet_points(results_obj, sql_query=sql_query_str) + else: + # Last resort: fall back to cleaned message formatter + rows_text = format_tool_message_for_display(f"{prelude}\n\nResult: {raw_str}") + # Avoid duplicating prelude inside rows_text + if rows_text.startswith(prelude): + rows_text = rows_text[len(prelude) :].lstrip() + + block = f"{prelude}\n\n{rows_text}".strip() + if block: + cleaned_blocks.append(mask_uuids_in_text(block)) + except Exception: + # Fail-safe: fall back to generic formatting + prelude = f"✅ {(tool_name or "Tool")} execution completed" + formatted = format_tool_message_for_display(f"{prelude}\n\nResult: {raw_str}") + if formatted and formatted.strip(): + cleaned_blocks.append(mask_uuids_in_text(formatted)) + + elif tname == "vector_search_tool": + # Truncate to first 50 words for external display + def _truncate_words(text: str, max_words: int = 50) -> str: + words = text.split() + if len(words) <= max_words: + return text + return " ".join(words[:max_words]) + "..." + + truncated = "" + # Try to extract 'results' from structured_result if present + if isinstance(exec_data, dict) and exec_data.get("structured_result"): + sr = exec_data.get("structured_result") + if isinstance(sr, dict): + res = sr.get("results") + if res is not None: + truncated = _truncate_words(str(res)) + if not truncated: + truncated = _truncate_words(str(raw_str)) + + from pi.services.chat.helpers.tool_utils import tool_name_shown_to_user + + prelude = f"✅ {tool_name_shown_to_user(tool_name or "Tool")} execution completed" + formatted = f"{prelude}\n\nResult: {truncated}" + cleaned_blocks.append(mask_uuids_in_text(formatted.strip())) + + else: + from pi.services.chat.helpers.tool_utils import tool_name_shown_to_user + + prelude = f"✅ {tool_name_shown_to_user(tool_name or "Tool")} execution completed" + # Include query context when available (helps external readers) + if isinstance(exec_data, dict) and exec_data.get("tool_query"): + try: + tq = str(exec_data.get("tool_query")).strip() + if tq: + prelude = f"{prelude} ({tq})" + except Exception: + pass + formatted = format_tool_message_for_display(f"{prelude}\n\nResult: {raw_str}") + if formatted and formatted.strip(): + cleaned_blocks.append(mask_uuids_in_text(formatted)) + + if cleaned_blocks: + base_reasoning = (qa_pair.get("reasoning") or "").strip() + if base_reasoning: + base_reasoning = mask_uuids_in_text(base_reasoning) + appended = "\n\n".join(cleaned_blocks) + qa_pair["reasoning"] = f"{base_reasoning}\n\n{appended}".strip() if base_reasoning else appended + except Exception: + # Fail-safe: do not block retrieval on formatting errors + pass + # Add execution information at dialogue level (only if there are actions) if execution_status_info.get("actions"): qa_pair.update({ @@ -523,57 +693,57 @@ async def retrieve_chat_history( execution_summaries: List[str] = [] # Extract information from flow steps for this message - selected_agents_results: List[str] = [] - agent_results: Dict[str, List[Any]] = {} + selected_tools_results: List[str] = [] + tool_results: Dict[str, List[Any]] = {} # Process flow steps to extract information for message_flow_step in message_specific_steps: content = parse_flow_step_content(message_flow_step.content or "") step_type = message_flow_step.step_type tool_name = message_flow_step.tool_name - if step_type == "routing": - # Handle routing step - content could be a dict with a list or a string - routing_data = None - if isinstance(content, dict): - # Check if content contains a list of routing results - routing_data = content.get("results") - elif isinstance(content, list): - routing_data = content - elif isinstance(content, str): - routing_data = content - if routing_data is not None: - if isinstance(routing_data, list): - # Content contains the list of selected agents - routing_results = [] - for d in routing_data: - if not isinstance(d, dict): - continue - tool_val = str(d.get("tool", "")).strip() - query_val = str(d.get("query", "")).strip() - if not tool_val or not query_val: - # Filter empty agent entries safely - continue - tool_disp = internal_reasoning_format_dict.get(tool_val, tool_val) - routing_results.append( - f"Selected Agent: {tool_disp} and the corresponding sub query: {query_val}" - ) - if routing_results: - selected_agents_results.extend(routing_results) - elif isinstance(routing_data, str): - selected_agents_results.append(routing_data) - else: - log.warning(f"Unexpected routing data type: {type(routing_data)}, routing_data: {routing_data}") - - elif step_type == "tool" and tool_name: - # Skip context-only metadata steps (don't clutter history) - if tool_name == "tool_orchestration_context": + if step_type == "tool" and tool_name: + # Handle tool selection specially - extract selected tools for display + if tool_name == "tool_selection": + content_data = parse_flow_step_content(message_flow_step.content or "") + if isinstance(content_data, dict): + selected_tools = content_data.get("selected_tools", []) + for tool_info in selected_tools: + if isinstance(tool_info, dict): + tool_display_name = tool_info.get("name", "") + # Format tool selection for enhanced history + selected_tools_results.append(f"Selected Tool: {tool_display_name}") continue - # Collect agent results - if tool_name not in agent_results: - agent_results[tool_name] = [] - agent_results[tool_name].append(content) + # Skip context-only metadata steps, verbose advisory content, and reasoning (don't clutter history) + if tool_name in ( + "tool_orchestration_context", + "llm_reasoning", + "get_available_plane_actions", + "planner_summary", + ): + continue + + # Skip intermediate retrieval tool steps (same as external reasoning) + # These are internal implementation details that shouldn't clutter the LLM context + if tool_name in ( + "structured_db_tool_table_selection", + "structured_db_tool_sql_generation", + "structured_db_tool_final_query", + ): + continue + + # For retrieval tools (non-planned), skip PENDING steps - they shouldn't be in history + if hasattr(message_flow_step, "execution_success") and hasattr(message_flow_step, "is_planned"): + if not message_flow_step.is_planned: + # This is a retrieval tool - only include if successfully executed + if message_flow_step.execution_success != ExecutionStatus.SUCCESS: + continue + + # Collect tool results + if tool_name not in tool_results: + tool_results[tool_name] = [] + tool_results[tool_name].append(content) # Add execution status information using new fields if hasattr(message_flow_step, "execution_success") and hasattr(message_flow_step, "is_planned"): @@ -735,11 +905,11 @@ async def retrieve_chat_history( # Prepare separate sections so downstream can reorder (selected vs executed) selected_section_lines: List[str] = [] - if selected_agents_results: + if selected_tools_results: selected_section_lines.append( - "The below agents were selected to retrieve relevant information to address the query:" + "The below tools were selected to retrieve relevant information to address the query:" ) - selected_section_lines.extend(selected_agents_results) + selected_section_lines.extend(selected_tools_results) selected_section_lines.append("") # Also add into internal_reasoning_parts for backwards-compatible single string internal_reasoning_parts.extend(selected_section_lines) @@ -752,12 +922,12 @@ async def retrieve_chat_history( # Also add into internal_reasoning_parts for backwards-compatible single string internal_reasoning_parts.extend(executed_section_lines) - # Process each agent's results - add only non-redundant, high-signal details + # Process each tool's results - add only non-redundant, high-signal details # Avoid duplicating raw results here since execution summaries above already include a results preview - for agent_name, agent_content_list in agent_results.items(): - for content in agent_content_list: + for tool_name_key, tool_content_list in tool_results.items(): + for content in tool_content_list: if isinstance(content, dict): - if agent_name == "PLANE_STRUCTURED_DATABASE_AGENT": + if tool_name_key == "structured_db_tool": # Include generated SQL query (useful context), but skip raw result dumps intermediate_results = content.get("intermediate_results", {}) if intermediate_results: @@ -778,20 +948,29 @@ async def retrieve_chat_history( f" issue unique key: {url_dict.get("issue_identifier")}" ) else: - # Skip adding additional raw results for other agents to avoid duplication + # Skip adding additional raw results for other tools to avoid duplication continue - internal_reasoning_parts.append("") # Add empty line between agents + internal_reasoning_parts.append("") # Add empty line between tools # Join all internal reasoning parts into a single formatted string message_internal_reasoning = "\n".join(internal_reasoning_parts) # Store both combined and split sections for downstream formatting control qa_pair["internal_reasoning"] = message_internal_reasoning - if selected_section_lines: - qa_pair["internal_selected"] = "\n".join(selected_section_lines).strip() - if executed_section_lines: - qa_pair["internal_executed"] = "\n".join(executed_section_lines).strip() + try: + if selected_tools_results: + qa_pair["internal_selected"] = "\n".join( + ["The below tools were selected to retrieve relevant information to address the query:"] + + selected_tools_results + ).strip() + except Exception: + pass + try: + if executed_section_lines: + qa_pair["internal_executed"] = "\n".join(executed_section_lines).strip() + except Exception: + pass dialogue_list.append(qa_pair) i += 2 @@ -821,10 +1000,18 @@ async def retrieve_chat_history( "query_id": str(user_message.id), "answer_id": "", # No assistant message yet "attachment_ids": attachments_dict.get(user_message.id, []), + "attachments": attachments_object_dict.get(user_message.id, []), # Add this line for consistency # Add execution status information for frontend "execution_status": execution_status_info, } + # Flatten actions and summary at the top-level for frontend compatibility (build mode) + if execution_status_info.get("actions"): + standalone_qa_pair.update({ + "action_summary": execution_status_info.get("action_summary", {}), + "actions": execution_status_info.get("actions", []), + }) + if pi_internal: # Generate internal reasoning for this standalone user message standalone_message_internal_reasoning = "" @@ -840,8 +1027,8 @@ async def retrieve_chat_history( # Extract information from flow steps for this message standalone_original_query = user_message.content or "" standalone_rewritten_query = "" - standalone_selected_agents_results: List[str] = [] - standalone_agent_results: Dict[str, List[Any]] = {} + standalone_selected_tools_results: List[str] = [] + standalone_tool_results: Dict[str, List[Any]] = {} # Process flow steps to extract information for message_flow_step in standalone_message_specific_steps: @@ -851,36 +1038,48 @@ async def retrieve_chat_history( if step_type == "rewrite" and isinstance(content, dict): standalone_rewritten_query = content.get("results", "") or content.get("rewritten_query", "") - elif step_type == "routing": - # Handle routing step - content could be a dict with a list or a string - routing_data = None - if isinstance(content, dict): - # Check if content contains a list of routing results - routing_data = content.get("results") - elif isinstance(content, list): - routing_data = content - elif isinstance(content, str): - routing_data = content - - if routing_data is not None: - if isinstance(routing_data, list): - # Content contains the list of selected agents - routing_results = [ - f"Selected Agent: {internal_reasoning_format_dict.get(d.get("tool", ""), d.get("tool", ""))} and the corresponding sub query: {d.get("query", "")}" # noqa: E501 - for d in routing_data - if isinstance(d, dict) - ] - standalone_selected_agents_results.extend(routing_results) - elif isinstance(routing_data, str): - standalone_selected_agents_results.append(routing_data) - else: - log.warning(f"Unexpected routing data type: {type(routing_data)}, routing_data: {routing_data}") elif step_type == "tool" and tool_name: - # Collect agent results - if tool_name not in standalone_agent_results: - standalone_agent_results[tool_name] = [] - standalone_agent_results[tool_name].append(content) + # Handle tool selection specially - extract selected tools for display + if tool_name == "tool_selection": + content_data = parse_flow_step_content(message_flow_step.content or "") + if isinstance(content_data, dict): + selected_tools = content_data.get("selected_tools", []) + for tool_info in selected_tools: + if isinstance(tool_info, dict): + tool_display_name = tool_info.get("name", "") + standalone_selected_tools_results.append(f"Selected Tool: {tool_display_name}") + continue + + # Skip context-only metadata steps, verbose advisory content, and reasoning + if tool_name in ( + "tool_orchestration_context", + "llm_reasoning", + "get_available_plane_actions", + "planner_summary", + ): + continue + + # Skip intermediate retrieval tool steps (same as external reasoning) + # These are internal implementation details that shouldn't clutter the LLM context + if tool_name in ( + "structured_db_tool_table_selection", + "structured_db_tool_sql_generation", + "structured_db_tool_final_query", + ): + continue + + # For retrieval tools (non-planned), skip PENDING steps - they shouldn't be in history + if hasattr(message_flow_step, "execution_success") and hasattr(message_flow_step, "is_planned"): + if not message_flow_step.is_planned: + # This is a retrieval tool - only include if successfully executed + if message_flow_step.execution_success != ExecutionStatus.SUCCESS: + continue + + # Collect tool results + if tool_name not in standalone_tool_results: + standalone_tool_results[tool_name] = [] + standalone_tool_results[tool_name].append(content) # Add execution status information using new fields if hasattr(message_flow_step, "execution_success") and hasattr(message_flow_step, "is_planned"): @@ -939,19 +1138,19 @@ async def retrieve_chat_history( standalone_internal_reasoning_parts.append(f"User question: '{cleaned_query}'") standalone_internal_reasoning_parts.append("") - if standalone_selected_agents_results: + if standalone_selected_tools_results: standalone_internal_reasoning_parts.append( - "The below agents were selected to retrieve relevant information to address the query:" + "The below tools were selected to retrieve relevant information to address the query:" ) - standalone_internal_reasoning_parts.extend(standalone_selected_agents_results) + standalone_internal_reasoning_parts.extend(standalone_selected_tools_results) standalone_internal_reasoning_parts.append("") - # Process each agent's results - for agent_name, agent_content_list in standalone_agent_results.items(): - for content in agent_content_list: + # Process each tool's results + for tool_name_key, tool_content_list in standalone_tool_results.items(): + for content in tool_content_list: if isinstance(content, dict): - # Handle structured database agent specifically - if agent_name == "PLANE_STRUCTURED_DATABASE_AGENT": + # Handle structured database tool specifically + if tool_name_key == "structured_db_tool": # standalone_internal_reasoning_parts.append("Intermediate results") # SQL query and results @@ -978,8 +1177,8 @@ async def retrieve_chat_history( f" issue unique key: {url_dict.get("issue_identifier")}" ) - # Handle vector search agent - elif agent_name == "PLANE_VECTOR_SEARCH_AGENT": + # Handle vector search tool + elif tool_name_key == "vector_search_tool": results = content.get("results", "") if results: standalone_internal_reasoning_parts.append("Semantic search results:") @@ -999,8 +1198,8 @@ async def retrieve_chat_history( standalone_internal_reasoning_parts.append( f" issue unique key: {url_dict.get("issue_identifier")}" ) - # Handle pages search agent - elif agent_name == "PLANE_PAGES_AGENT": + # Handle pages search tool + elif tool_name_key == "pages_search_tool": results = content.get("results", "") if results: standalone_internal_reasoning_parts.append("Pages search results:") @@ -1008,7 +1207,7 @@ async def retrieve_chat_history( else: standalone_internal_reasoning_parts.append("Pages search results: No results found") - # Generic handling for other agents + # Generic handling for other tools else: results = content.get("results", "") if results: @@ -1019,7 +1218,7 @@ async def retrieve_chat_history( standalone_internal_reasoning_parts.append("Results:") standalone_internal_reasoning_parts.append(content) - standalone_internal_reasoning_parts.append("") # Add empty line between agents + standalone_internal_reasoning_parts.append("") # Add empty line between tools # Join all internal reasoning parts into a single formatted string standalone_message_internal_reasoning = "\n".join(standalone_internal_reasoning_parts) @@ -1031,7 +1230,8 @@ async def retrieve_chat_history( else: i += 1 - return { + # Return both new polymorphic structure and legacy fields for backward compatibility + response = { "chat_id": str(chat_id), "title": chat.title or "", "dialogue": dialogue_list, @@ -1039,13 +1239,19 @@ async def retrieve_chat_history( "reasoning": "", "llm": chat_llm, "is_focus_enabled": user_chat_preference.is_focus_enabled if user_chat_preference else False, + # New polymorphic structure + "focus_entity_type": user_chat_preference.focus_entity_type if user_chat_preference and user_chat_preference.focus_entity_type else None, + "focus_entity_id": str(user_chat_preference.focus_entity_id) if user_chat_preference and user_chat_preference.focus_entity_id else None, + # Legacy fields (for backward compatibility) "focus_project_id": str(user_chat_preference.focus_project_id) if user_chat_preference and user_chat_preference.focus_project_id else None, "focus_workspace_id": str(user_chat_preference.focus_workspace_id) if user_chat_preference and user_chat_preference.focus_workspace_id else None, + "mode": user_chat_preference.mode if user_chat_preference and user_chat_preference.mode else "ask", } + return response except Exception as e: log.error(f"Error retrieving chat history: {e}") @@ -1057,8 +1263,11 @@ async def retrieve_chat_history( "reasoning": "", "llm": "", "is_focus_enabled": False, + "focus_entity_type": None, + "focus_entity_id": None, "focus_project_id": None, "focus_workspace_id": None, + "mode": "ask", } @@ -1495,25 +1704,54 @@ async def upsert_user_chat_preference( chat_id: UUID4, db: AsyncSession, is_focus_enabled: Optional[bool] = None, + # New polymorphic parameters + focus_entity_type: Optional[str] = None, + focus_entity_id: Optional[UUID4] = None, + # Legacy parameters (for backward compatibility) focus_project_id: Optional[UUID4] = None, focus_workspace_id: Optional[UUID4] = None, + mode: Optional[Literal["ask", "build"]] = "ask", ) -> Dict[str, Any]: """ Upserts a user chat preference. Returns a dictionary with operation status and the user chat preference object or error details. + + Supports both new polymorphic structure (focus_entity_type, focus_entity_id) and + legacy structure (focus_project_id, focus_workspace_id) for backward compatibility. + New parameters take precedence over legacy parameters. """ try: stmt = select(UserChatPreference).where(UserChatPreference.user_id == user_id).where(UserChatPreference.chat_id == chat_id) # type: ignore[union-attr,arg-type] result = await db.execute(stmt) existing_user_chat_preference = result.scalar_one_or_none() + # Determine focus context: prioritize new polymorphic params, fall back to legacy + final_focus_entity_type = focus_entity_type + final_focus_entity_id = focus_entity_id + + if not final_focus_entity_type and not final_focus_entity_id: + # Fall back to legacy parameters + if focus_project_id: + final_focus_entity_type = "project" + final_focus_entity_id = focus_project_id + elif focus_workspace_id: + final_focus_entity_type = "workspace" + final_focus_entity_id = focus_workspace_id + if existing_user_chat_preference: if is_focus_enabled is not None: existing_user_chat_preference.is_focus_enabled = is_focus_enabled + if final_focus_entity_type is not None: + existing_user_chat_preference.focus_entity_type = final_focus_entity_type + if final_focus_entity_id is not None: + existing_user_chat_preference.focus_entity_id = final_focus_entity_id + # Also update legacy fields for backward compatibility during migration if focus_project_id is not None: existing_user_chat_preference.focus_project_id = focus_project_id if focus_workspace_id is not None: existing_user_chat_preference.focus_workspace_id = focus_workspace_id + if mode is not None: + existing_user_chat_preference.mode = mode db.add(existing_user_chat_preference) await db.commit() return {"message": "success", "user_chat_preference": existing_user_chat_preference} @@ -1524,10 +1762,17 @@ async def upsert_user_chat_preference( } if is_focus_enabled is not None: new_user_chat_preference_kwargs["is_focus_enabled"] = is_focus_enabled + if final_focus_entity_type is not None: + new_user_chat_preference_kwargs["focus_entity_type"] = final_focus_entity_type + if final_focus_entity_id is not None: + new_user_chat_preference_kwargs["focus_entity_id"] = final_focus_entity_id + # Also set legacy fields for backward compatibility during migration if focus_project_id is not None: new_user_chat_preference_kwargs["focus_project_id"] = focus_project_id if focus_workspace_id is not None: new_user_chat_preference_kwargs["focus_workspace_id"] = focus_workspace_id + if mode is not None: + new_user_chat_preference_kwargs["mode"] = mode new_user_chat_preference = UserChatPreference(**new_user_chat_preference_kwargs) db.add(new_user_chat_preference) await db.commit() @@ -1536,3 +1781,40 @@ async def upsert_user_chat_preference( await db.rollback() log.error(f"Error upserting user chat preference: {e}") return {"message": "error", "error": str(e)} + + +async def get_project_from_chat_preference(chat_id: str, user_id: str, db: AsyncSession) -> Tuple[Optional[str], bool]: + # Get is_project_chat from chat record (with explicit commit/refresh to avoid race condition) + # Refresh the session to ensure we see any committed chat records + await db.commit() + + stmt = select(Chat).where(Chat.id == chat_id) # type: ignore[arg-type] + result = await db.execute(stmt) + chat = result.scalar_one_or_none() + is_project_chat = chat.is_project_chat if chat else False + + # Get project_id from UserChatPreference + project_id = None + try: + pref_stmt = select(UserChatPreference).where( + UserChatPreference.chat_id == chat_id, # type: ignore[arg-type] + UserChatPreference.user_id == user_id, # type: ignore[arg-type] + ) + result = await db.execute(pref_stmt) + pref = result.scalar_one_or_none() + + if pref: + # Check polymorphic focus first + if pref.focus_entity_type == FocusEntityType.PROJECT.value and pref.focus_entity_id: + project_id = pref.focus_entity_id + # Fallback to legacy field + elif pref.focus_project_id: + project_id = pref.focus_project_id + + if project_id: + log.info(f"🔧 Found project_id in UserChatPreference: {project_id}") + except Exception as e: + log.error(f"Error fetching UserChatPreference: {e}") + + project_id = str(project_id) if project_id else None + return project_id, is_project_chat diff --git a/apps/pi/pi/services/retrievers/pg_store/message.py b/apps/pi/pi/services/retrievers/pg_store/message.py index 108ff6dd50..0ee906a0ff 100644 --- a/apps/pi/pi/services/retrievers/pg_store/message.py +++ b/apps/pi/pi/services/retrievers/pg_store/message.py @@ -19,6 +19,7 @@ from pi.app.models import Message from pi.app.models import MessageFeedback from pi.app.models import MessageFlowStep from pi.app.models import MessageMeta +from pi.app.models import UserChatPreference from pi.app.models.enums import ExecutionStatus from pi.app.models.enums import FlowStepType from pi.app.models.enums import MessageFeedbackTypeChoices @@ -200,6 +201,7 @@ async def upsert_message( reasoning: Optional[str] = None, parsed_content: Optional[str] = None, workspace_slug: Optional[str] = None, + source: Optional[str] = None, ) -> Dict[str, Any]: """ Creates a new message or updates an existing one. @@ -243,6 +245,8 @@ async def upsert_message( existing_message.parsed_content = parsed_content if workspace_slug is not None: existing_message.workspace_slug = workspace_slug + if source is not None: + existing_message.source = source # updated_at will be handled by SQLAlchemy db.add(existing_message) await db.commit() @@ -273,6 +277,7 @@ async def upsert_message( llm_model_id=None, # Add the missing required parameter reasoning=reasoning or "", workspace_slug=workspace_slug, + source=source, ) db.add(new_message) await db.commit() @@ -494,13 +499,14 @@ async def upsert_message_meta( workspace_slug = msg.workspace_slug if meta: - # --- UPDATE --- - meta.input_text_tokens = in_tokens - meta.input_text_price = input_cost_usd - meta.output_text_tokens = out_tokens - meta.output_text_price = output_cost_usd - meta.cached_input_text_tokens = cached_in_tokens - meta.cached_input_text_price = cached_input_cost_usd + # --- UPDATE --- Aggregate tokens and costs instead of overwriting + # This handles cases where multiple LLM calls occur for the same message_id + step_type + meta.input_text_tokens = (meta.input_text_tokens or 0) + in_tokens + meta.input_text_price = (meta.input_text_price or 0.0) + input_cost_usd + meta.output_text_tokens = (meta.output_text_tokens or 0) + out_tokens + meta.output_text_price = (meta.output_text_price or 0.0) + output_cost_usd + meta.cached_input_text_tokens = (meta.cached_input_text_tokens or 0) + cached_in_tokens + meta.cached_input_text_price = (meta.cached_input_text_price or 0.0) + cached_input_cost_usd meta.llm_model_id = llm_model_id meta.llm_model_pricing_id = pricing_id if workspace_slug is not None: @@ -636,6 +642,20 @@ async def reconstruct_chat_request_from_message(db: AsyncSession, user_message: attachments = attachments_result.scalars().all() attachment_ids = [att.attachment_id for att in attachments] if attachments else [] + # Get user chat preferences to retrieve mode + preferences_stmt = ( + select(UserChatPreference) + .where(UserChatPreference.chat_id == user_message.chat_id) # type: ignore[arg-type] + .where(UserChatPreference.deleted_at.is_(None)) # type: ignore[union-attr] + ) + preferences_result = await db.execute(preferences_stmt) + user_preferences = preferences_result.scalar_one_or_none() + mode = user_preferences.mode if user_preferences and user_preferences.mode else "ask" + project_id = user_preferences.focus_project_id if user_preferences and user_preferences.focus_project_id else None + + # Get source from message, default to "web" if not set + source = user_message.source or "web" + # Reconstruct ChatRequest return ChatRequest( query=user_message.content or "", @@ -648,8 +668,9 @@ async def reconstruct_chat_request_from_message(db: AsyncSession, user_message: workspace_slug=chat.workspace_slug if chat else None, workspace_in_context=chat.workspace_in_context if chat else False, is_project_chat=chat.is_project_chat if chat else False, - project_id=None, # Will be resolved from context if needed + project_id=project_id, attachment_ids=attachment_ids, context={"token_id": str(user_message.id)}, # Reuse same message ID - source="web", + source=source, + mode=mode, ) diff --git a/apps/pi/pi/services/schemas/chat.py b/apps/pi/pi/services/schemas/chat.py index f6207d2771..c45138282b 100644 --- a/apps/pi/pi/services/schemas/chat.py +++ b/apps/pi/pi/services/schemas/chat.py @@ -10,24 +10,35 @@ from pydantic import BaseModel from pydantic import Field -class Agents(str, Enum): - GENERIC_AGENT = "generic_agent" - PLANE_STRUCTURED_DATABASE_AGENT = "plane_structured_database_agent" - PLANE_VECTOR_SEARCH_AGENT = "plane_vector_search_agent" - PLANE_PAGES_AGENT = "plane_pages_agent" - PLANE_DOCS_AGENT = "plane_docs_agent" - PLANE_ACTION_EXECUTOR_AGENT = "plane_action_executor_agent" +class RetrievalTools(str, Enum): + """Enumeration of available retrieval tools (formerly called agents).""" + + STRUCTURED_DB_TOOL = "structured_db_tool" + VECTOR_SEARCH_TOOL = "vector_search_tool" + PAGES_SEARCH_TOOL = "pages_search_tool" + DOCS_SEARCH_TOOL = "docs_search_tool" + ACTION_EXECUTOR_TOOL = "action_executor_tool" -class AgentQuery(BaseModel): - agent: Agents - query: str = Field(..., description="The decomposed query for this specific agent") +# Legacy alias for backward compatibility during migration +Agents = RetrievalTools + + +class ToolQuery(BaseModel): + """A query targeted at a specific retrieval tool.""" + + tool: RetrievalTools + query: str = Field(..., description="The decomposed query for this specific tool") + + +# Legacy alias for backward compatibility during migration +AgentQuery = ToolQuery class RouteQuery(BaseModel): - """Route a user query to the most relevant customer service agent(s) with decomposed queries.""" + """Route a user query to the most relevant retrieval tool(s) with decomposed queries.""" - decomposed_queries: list[AgentQuery] = Field(..., description="List of agents with their corresponding decomposed queries") + decomposed_queries: list[ToolQuery] = Field(..., description="List of tools with their corresponding decomposed queries") class RoutingResult(TypedDict): @@ -36,13 +47,20 @@ class RoutingResult(TypedDict): parsing_error: Exception | None -AgentQueryList: TypeAlias = list[AgentQuery] +ToolQueryList: TypeAlias = list[ToolQuery] + +# Legacy alias for backward compatibility during migration +AgentQueryList = ToolQueryList -class AgentOrder(BaseModel): - """Provide the order in which the selected data retrieval agents should be executed.""" +class ToolOrder(BaseModel): + """Provide the order in which the selected data retrieval tools should be executed.""" - ordered_agents: List[str] = Field(..., description="List of ordered agent names") + ordered_tools: List[str] = Field(..., description="List of ordered tool names") + + +# Legacy alias for backward compatibility during migration +AgentOrder = ToolOrder class QueryFlowStore(TypedDict): @@ -61,6 +79,8 @@ class QueryFlowStore(TypedDict): workspace_in_context: bool project_id: str workspace_id: str + # Optional: current step order to align sub-step persistence without DB lookups + step_order: int # --- Action Category Routing (for hierarchical actions) --- @@ -88,6 +108,7 @@ class ActionCategorySelection(BaseModel): "properties", "types", "worklogs", + "retrieval_tools", ] rationale: Optional[str] = None diff --git a/apps/pi/pi/tests/example_slack_answer_request.py b/apps/pi/pi/tests/example_slack_answer_request.py new file mode 100644 index 0000000000..57c0ed30de --- /dev/null +++ b/apps/pi/pi/tests/example_slack_answer_request.py @@ -0,0 +1,145 @@ +""" +Example Python requests code to hit the /slack/answer/ endpoint. + +This endpoint is located at: {base_path}/api/v1/chat/slack/answer/ +""" + +import json +from uuid import UUID +from uuid import uuid4 + +import requests + +# Configuration +BASE_URL = "http://localhost:8001" # Update with your actual base URL +BASE_PATH = "" # Update if your API has a base path (e.g., "/pi") +AUTHORIZATION_TOKEN = "plane_api_c9a0aae9011c483bae231b655afac38d" # Your Plane access token or API key +# AUTHORIZATION_TOKEN = "plane_api_c9a0aae9011c483bae231b655afac38d" # Your Plane access token or API key + +# Endpoint URL +endpoint_url = f"{BASE_URL}{BASE_PATH}/api/v1/chat/slack/answer/" + +llm = "gpt-4.1" +# llm = 'gpt-5-standard' +# llm = 'gpt-5-fast' +# llm = 'claude-sonnet-4' +user_id = UUID("c1cc72be-3b92-445d-b223-6bcd94876925") +# user_id = UUID("2225dd79-dffe-4272-9748-3f88548daddb") +# user_id = UUID("6baf27f5-1bfd-4acb-aa2f-333cd23130e0") +workspace_id = "42200c3d-1225-4cb5-88ba-c88af80430f2" +# workspace_id = "cd4ab5a2-1a5f-4516-a6c6-8da1a9fa5be4" +workspace_slug = None +# workspace_id = "23d3924f-db82-4377-b8d3-6ff643af6b9a" +# workspace_id = "31f16e25-535e-404f-946e-3ffbe52695a2" +# workspace_id = "1559196e-fdd9-4651-8a78-33e30cbf5f72" +# workspace_id = None +is_temp = False +workspace_in_context = True +is_new = True +pi_sidebar_open = True +sidebar_open_url = r"piworkspace/projects/3719d6d4-42d6-4bf5-bfe4-85dc33e07958/cycles/28ec0918-9095-42c7-80ae-864816d8dbd2/" +is_project_chat = False +# project_id = "40bc0eaf-2d34-457a-ae5b-d4040c42edcc" +project_id = "47ed2e28-3e91-4ba8-a463-4a117941e2d4" +# project_id=None +# project_id = "743525c9-7ac3-4570-8262-e63330175ed4" +context = {"first_name": "sunder", "last_name": "Chintada"} + +query = """ +This was the previous conversation history: \n\n +===================\n\n +User: give me one issue assigned to me in PALL project +Assistant: There are currently no work-items assigned to you in the PALL project. If you need help finding work-items in another project or have a different request, just let me know! +Assistant: There are currently no work-items assigned to you in the PALL project. If you need help finding work-items in another project or want to adjust your search, just let me know! +User: create a task in PALL project title - "Hello task" and assign it to me +Assistant: Successfully created work item 'Hello task' - + +User: do I have any task assigned to me in PALL project. If yes, show it +Assistant: Yes, you currently have one task assigned to you in the PALL project: + +• *Task Name:* Hello task +• *Unique Key:* Not provided in the data +• *Priority:* None +• *State:* Backlog + +If you need more details or want to take action on this task, just let me know! +User: what project are we discussing in this thread +Assistant: It seems like you're referring to a previous conversation about the PALL project. If you have any specific questions or need further information about the PALL project, feel free to ask! +User: create a task with title Test World +Assistant: Successfully created work item 'Test World' - + +User: what are we discussing? +Assistant: I'm sorry, but I don't have access to past conversation history. However, I can help with any questions or information you need right now. Feel free to ask! +User: try again +Assistant: I'm here to help with any questions or tasks you have. However, I don't have access to past conversation history. If you have any specific questions or need assistance, feel free to let me know! +User: past conversation is available under `This was the previous conversation history` what are we discussing +Assistant: I'm sorry, but I don't have access to the previous conversation history. However, if you provide me with some details or context about the project and work items, I'd be happy to help you summarize or discuss them further! +User: summarize this thread +Assistant: I'm sorry, but I can't provide a summary of the conversation history as there is no prior conversation content available. If you have specific questions or need assistance with a project, feel free to let me know! + +===================\n\n +πCurrent user messageπ:\ +What are we discussing in this thread? +""" # noqa E501 +query = """ +πCurrent user messageπ: +create a task with title Test World in Starlette project +""" +# Request payload - ChatRequest schema +payload = { + "query": query, + "llm": llm, # Optional: defaults to DEFAULT_LLM if not provided + "is_new": True, + "user_id": str(user_id), # Optional: UUID as string + "chat_id": str(uuid4()), # Optional: UUID as string + "is_temp": False, + "workspace_in_context": True, + "workspace_id": str(workspace_id), # Optional: UUID as string or empty string + "project_id": None, # Optional: UUID as string or None + "context": {}, # Required: dictionary with any context data + "is_project_chat": False, # Optional + "pi_sidebar_open": False, # Optional + "workspace_slug": "switchertest", # Optional: workspace slug string + "attachment_ids": [], # Optional: list of UUID strings + "sidebar_open_url": "", # Optional + "source": "app", # Optional: source identifier +} + +# Headers +headers = { + "Authorization": f"Bearer {AUTHORIZATION_TOKEN}", # Can also be just the token without "Bearer " + "Content-Type": "application/json", +} + +# Make the request +try: + response = requests.post( + endpoint_url, + json=payload, + headers=headers, + timeout=300, # Adjust timeout as needed (streaming can take time) + ) + + # Check response status + response.raise_for_status() + + # Parse JSON response + result = response.json() + + print("Response Status:", response.status_code) + print("\nResponse Body:") + print(json.dumps(result, indent=2)) + + # Access response fields + print("\n--- Response Fields ---") + print(f"Response Type: {result.get("response_type")}") + print(f"Response: {result.get("response")}") + print(f"Actions Data: {result.get("actions_data")}") + print(f"Context: {result.get("context")}") + print(f"Clarification Data: {result.get("clarification_data")}") + +except requests.exceptions.HTTPError as e: + print(f"HTTP Error: {e}") + print(f"Response: {e.response.text}") +except requests.exceptions.RequestException as e: + print(f"Request Error: {e}") diff --git a/apps/pi/pi/tests/services/test_cycles_add_work_items.py b/apps/pi/pi/tests/services/test_cycles_add_work_items.py new file mode 100644 index 0000000000..11dfb5218d --- /dev/null +++ b/apps/pi/pi/tests/services/test_cycles_add_work_items.py @@ -0,0 +1,74 @@ +""" +Test for cycles.add_work_items method to verify the fix for the 'data' keyword argument error. + +This test can be run with a real API token to verify the fix works correctly. +""" + +import os + +import pytest + +from pi.services.actions.plane_sdk_adapter import PlaneSDKAdapter + + +class TestCyclesAddWorkItems: + """Test cycles.add_work_items method.""" + + @pytest.mark.skipif( + not os.getenv("PLANE_API_TOKEN"), + reason="PLANE_API_TOKEN environment variable not set. Set it to run this test with real API.", + ) + def test_add_work_items_to_cycle(self): + """Test adding work items to a cycle with real API.""" + # Get API token from environment + api_token = os.getenv("PLANE_API_TOKEN") + workspace_slug = os.getenv("PLANE_WORKSPACE_SLUG", "test-workspace") + project_id = os.getenv("PLANE_PROJECT_ID", "test-project") + cycle_id = os.getenv("PLANE_CYCLE_ID", "test-cycle") + work_item_id = os.getenv("PLANE_WORK_ITEM_ID", "test-work-item") + + # Create adapter + adapter = PlaneSDKAdapter(access_token=api_token) + + # Test adding work items to cycle + # This should work without the 'data' keyword argument error + result = adapter.add_cycle_work_items( + workspace_slug=workspace_slug, + project_id=project_id, + cycle_id=cycle_id, + issues=[work_item_id], + ) + + # Verify result + assert result is not None + print("✅ Successfully added work items to cycle!") + print(f" Result: {result}") + + @pytest.mark.skipif( + not os.getenv("PLANE_API_TOKEN"), + reason="PLANE_API_TOKEN environment variable not set. Set it to run this test with real API.", + ) + def test_add_work_items_to_cycle_with_multiple_issues(self): + """Test adding multiple work items to a cycle.""" + api_token = os.getenv("PLANE_API_TOKEN") + workspace_slug = os.getenv("PLANE_WORKSPACE_SLUG", "test-workspace") + project_id = os.getenv("PLANE_PROJECT_ID", "test-project") + cycle_id = os.getenv("PLANE_CYCLE_ID", "test-cycle") + work_item_ids = os.getenv("PLANE_WORK_ITEM_IDS", "item1,item2").split(",") + + adapter = PlaneSDKAdapter(access_token=api_token) + + result = adapter.add_cycle_work_items( + workspace_slug=workspace_slug, + project_id=project_id, + cycle_id=cycle_id, + issues=work_item_ids, + ) + + assert result is not None + print(f"✅ Successfully added {len(work_item_ids)} work items to cycle!") + print(f" Result: {result}") + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"]) diff --git a/apps/pi/pi/tests/services/test_plane_api_methods.py b/apps/pi/pi/tests/services/test_plane_api_methods.py deleted file mode 100644 index f9ff9047a6..0000000000 --- a/apps/pi/pi/tests/services/test_plane_api_methods.py +++ /dev/null @@ -1,796 +0,0 @@ -""" -Comprehensive Test Suite for Plane API Methods Signature Validation. - -This test suite validates that ALL 81 implemented Plane API methods have signatures -that exactly match the Plane SDK specifications. It covers 17 API categories including -the original 8 categories (36 methods) plus 9 new categories (45 methods). - -Coverage: -- Projects API (4 methods) -- Work Items API (4 methods) -- Cycles API (10 methods) -- Labels API (2 methods) -- States API (2 methods) -- Modules API (8 methods) -- Assets API (5 methods) -- Users API (1 method) -- Intake API (5 methods) - NEW -- Members API (2 methods) - NEW -- Activity API (2 methods) - NEW -- Attachments API (4 methods) - NEW -- Comments API (5 methods) - NEW -- Links API (5 methods) - NEW -- Properties API (12 methods) - NEW -- Types API (5 methods) - NEW -- Worklogs API (5 methods) - NEW - -This test suite does NOT make actual API calls but focuses on parameter validation, -method signature compliance, and SDK request object usage. -""" - -from unittest.mock import AsyncMock - -import pytest - -from pi.services.actions.method_executor import MethodExecutor - -# Configure pytest for async testing -pytest_plugins = ("pytest_asyncio",) - - -class TestPlaneAPIMethods: - """Test class for validating Plane API method signatures against SDK specifications.""" - - @pytest.fixture - def method_executor(self): - """Create a MethodExecutor instance with mocked execute method.""" - - # Create a mock MethodExecutor that returns success for all execute calls - mock_executor = AsyncMock() - mock_executor.execute = AsyncMock(return_value={"success": True, "data": "test_data"}) - mock_executor.get_category_methods = MethodExecutor.get_category_methods - - return mock_executor - - @pytest.mark.asyncio - async def test_projects_api_method_signatures(self, method_executor): - """Test Projects API method signatures match SDK specifications.""" - - # Test projects retrieve - SDK: retrieve_project(pk, slug) - result = await method_executor.execute("projects", "retrieve", pk="project-123", slug="test-workspace") - assert result["success"] is True - - # Test projects update - SDK: update_project(pk, slug, patched_project_update_request=None) - update_data = {"name": "Updated Project", "description": "Updated description"} - result = await method_executor.execute( - "projects", "update", pk="project-123", slug="test-workspace", patched_project_update_request=update_data - ) - assert result["success"] is True - - # Test projects archive - SDK: archive_project(project_id, slug) - result = await method_executor.execute("projects", "archive", project_id="project-123", slug="test-workspace") - assert result["success"] is True - - # Test projects unarchive - SDK: unarchive_project(project_id, slug) - result = await method_executor.execute("projects", "unarchive", project_id="project-123", slug="test-workspace") - assert result["success"] is True - - @pytest.mark.asyncio - async def test_workitems_api_method_signatures(self, method_executor): - """Test Work Items API method signatures match SDK specifications.""" - - # Test workitems list with all optional parameters - result = await method_executor.execute( - "workitems", - "list", - project_id="project-123", - slug="test-workspace", - cursor="cursor-123", - expand="assignees,labels", - external_id="ext-123", - external_source="jira", - fields="name,description", - order_by="-created_at", - per_page=50, - ) - assert result["success"] is True - - # Test workitems retrieve with all optional parameters - result = await method_executor.execute( - "workitems", - "retrieve", - pk="issue-123", - project_id="project-123", - slug="test-workspace", - expand="assignees,labels", - external_id="ext-123", - external_source="jira", - fields="name,description", - order_by="-created_at", - ) - assert result["success"] is True - - # Test workitems search - result = await method_executor.execute( - "workitems", "search", search="bug report", slug="test-workspace", limit=25, project_id="project-123", workspace_search=True - ) - assert result["success"] is True - - # Test workitems get_workspace - result = await method_executor.execute("workitems", "get_workspace", issue_identifier="123", project_identifier="TEST", slug="test-workspace") - assert result["success"] is True - - @pytest.mark.asyncio - async def test_cycles_api_method_signatures(self, method_executor): - """Test Cycles API method signatures match SDK specifications.""" - - # Test cycles retrieve - result = await method_executor.execute("cycles", "retrieve", pk="cycle-123", project_id="project-123", slug="test-workspace") - assert result["success"] is True - - # Test cycles update with request object - update_data = {"name": "Updated Sprint", "description": "Updated description"} - result = await method_executor.execute( - "cycles", "update", pk="cycle-123", project_id="project-123", slug="test-workspace", patched_cycle_update_request=update_data - ) - assert result["success"] is True - - # Test cycles add_work_items with request object - request_data = {"issues": ["issue-1", "issue-2"]} - result = await method_executor.execute( - "cycles", - "add_work_items", - cycle_id="cycle-123", - project_id="project-123", - slug="test-workspace", - cycle_issue_request_request=request_data, - ) - assert result["success"] is True - - @pytest.mark.asyncio - async def test_labels_api_method_signatures(self, method_executor): - """Test Labels API method signatures match SDK specifications.""" - - # Test labels retrieve - result = await method_executor.execute("labels", "retrieve", pk="label-123", project_id="project-123", slug="test-workspace") - assert result["success"] is True - - # Test labels update with request object - update_data = {"name": "Updated Bug", "color": "#ff0000", "description": "Updated description"} - result = await method_executor.execute( - "labels", "update", pk="label-123", project_id="project-123", slug="test-workspace", patched_label_create_update_request=update_data - ) - assert result["success"] is True - - @pytest.mark.asyncio - async def test_states_api_method_signatures(self, method_executor): - """Test States API method signatures match SDK specifications.""" - - # Test states retrieve - note the parameter order: project_id, slug, state_id - result = await method_executor.execute("states", "retrieve", project_id="project-123", slug="test-workspace", state_id="state-123") - assert result["success"] is True - - # Test states update with request object - update_data = {"name": "In Review", "color": "#ffaa00", "description": "Work items under review"} - result = await method_executor.execute( - "states", "update", project_id="project-123", slug="test-workspace", state_id="state-123", patched_state_request=update_data - ) - assert result["success"] is True - - @pytest.mark.asyncio - async def test_modules_api_method_signatures(self, method_executor): - """Test Modules API method signatures match SDK specifications.""" - - # Test modules retrieve - result = await method_executor.execute("modules", "retrieve", pk="module-123", project_id="project-123", slug="test-workspace") - assert result["success"] is True - - # Test modules update with request object - update_data = {"name": "Updated Module", "description": "Updated description"} - result = await method_executor.execute( - "modules", "update", pk="module-123", project_id="project-123", slug="test-workspace", patched_module_update_request=update_data - ) - assert result["success"] is True - - # Test modules add_work_items with request object - request_data = {"issues": ["issue-1", "issue-2"]} - result = await method_executor.execute( - "modules", - "add_work_items", - module_id="module-123", - project_id="project-123", - slug="test-workspace", - module_issue_request_request=request_data, - ) - assert result["success"] is True - - # Test modules remove_work_item - note parameter order: issue_id, module_id, project_id, slug - result = await method_executor.execute( - "modules", "remove_work_item", issue_id="issue-123", module_id="module-123", project_id="project-123", slug="test-workspace" - ) - assert result["success"] is True - - @pytest.mark.asyncio - async def test_assets_api_method_signatures(self, method_executor): - """Test Assets API method signatures match SDK specifications.""" - - # Test assets create_user_upload with request object - request_data = {"name": "test_image.png", "size": 1024, "type": "image"} - result = await method_executor.execute("assets", "create_user_upload", user_asset_upload_request=request_data) - assert result["success"] is True - - # Test assets get_generic - result = await method_executor.execute("assets", "get_generic", asset_id="asset-123", slug="test-workspace") - assert result["success"] is True - - # Test assets update_generic with request object - update_data = {"name": "Updated Asset", "description": "Updated description"} - result = await method_executor.execute( - "assets", "update_generic", asset_id="asset-123", slug="test-workspace", patched_generic_asset_update_request=update_data - ) - assert result["success"] is True - - # Test assets update_user with request object - update_data = {"name": "Updated User Asset"} - result = await method_executor.execute("assets", "update_user", asset_id="asset-123", patched_asset_update_request=update_data) - assert result["success"] is True - - # Test assets delete_user - result = await method_executor.execute("assets", "delete_user", asset_id="asset-123") - assert result["success"] is True - - @pytest.mark.asyncio - async def test_users_api_method_signatures(self, method_executor): - """Test Users API method signatures match SDK specifications.""" - - # Test users get_current - no parameters required - result = await method_executor.execute("users", "get_current") - assert result["success"] is True - - @pytest.mark.asyncio - async def test_intake_api_method_signatures(self, method_executor): - """Test Intake API method signatures match SDK specifications.""" - - # Test intake create with request object - intake_request = {"name": "New Intake Item", "description_html": "

Test description

", "priority": "medium"} - result = await method_executor.execute( - "intake", "create", project_id="project-123", slug="test-workspace", intake_issue_create_request=intake_request - ) - assert result["success"] is True - - # Test intake delete - result = await method_executor.execute("intake", "delete", intake_id="intake-123", project_id="project-123", slug="test-workspace") - assert result["success"] is True - - # Test intake list with pagination - result = await method_executor.execute("intake", "list", project_id="project-123", slug="test-workspace", cursor="cursor-123", per_page=20) - assert result["success"] is True - - # Test intake retrieve - result = await method_executor.execute("intake", "retrieve", intake_id="intake-123", project_id="project-123", slug="test-workspace") - assert result["success"] is True - - # Test intake update with request object - update_data = {"name": "Updated Intake", "priority": "high"} - result = await method_executor.execute( - "intake", - "update", - intake_id="intake-123", - project_id="project-123", - slug="test-workspace", - patched_intake_issue_update_request=update_data, - ) - assert result["success"] is True - - @pytest.mark.asyncio - async def test_members_api_method_signatures(self, method_executor): - """Test Members API method signatures match SDK specifications.""" - - # Test get_project_members - result = await method_executor.execute("members", "get_project_members", project_id="project-123", slug="test-workspace") - assert result["success"] is True - - # Test get_workspace_members - result = await method_executor.execute("members", "get_workspace_members", slug="test-workspace") - assert result["success"] is True - - @pytest.mark.asyncio - async def test_activity_api_method_signatures(self, method_executor): - """Test Work Item Activity API method signatures match SDK specifications.""" - - # Test list_work_item_activities with all optional parameters - result = await method_executor.execute( - "activity", - "list", - issue_id="issue-123", - project_id="project-123", - slug="test-workspace", - cursor="cursor-123", - expand="actor,issue", - fields="activity_type,created_at", - order_by="-created_at", - per_page=20, - ) - assert result["success"] is True - - # Test retrieve_work_item_activity - result = await method_executor.execute( - "activity", "retrieve", activity_id="activity-123", issue_id="issue-123", project_id="project-123", slug="test-workspace" - ) - assert result["success"] is True - - @pytest.mark.asyncio - async def test_attachments_api_method_signatures(self, method_executor): - """Test Work Item Attachments API method signatures match SDK specifications.""" - - # Test create_work_item_attachment with request object - attachment_request = {"name": "test_file.pdf", "size": 2048, "asset_type": "attachment"} - result = await method_executor.execute( - "attachments", - "create", - issue_id="issue-123", - project_id="project-123", - slug="test-workspace", - issue_attachment_upload_request=attachment_request, - ) - assert result["success"] is True - - # Test delete_work_item_attachment - result = await method_executor.execute( - "attachments", "delete", attachment_id="attachment-123", issue_id="issue-123", project_id="project-123", slug="test-workspace" - ) - assert result["success"] is True - - # Test list_work_item_attachments - result = await method_executor.execute("attachments", "list", issue_id="issue-123", project_id="project-123", slug="test-workspace") - assert result["success"] is True - - # Test retrieve_work_item_attachment - result = await method_executor.execute( - "attachments", "retrieve", attachment_id="attachment-123", issue_id="issue-123", project_id="project-123", slug="test-workspace" - ) - assert result["success"] is True - - @pytest.mark.asyncio - async def test_comments_api_method_signatures(self, method_executor): - """Test Work Item Comments API method signatures match SDK specifications.""" - - # Test create_work_item_comment with request object - comment_request = {"comment_html": "

This is a test comment

"} - result = await method_executor.execute( - "comments", "create", issue_id="issue-123", project_id="project-123", slug="test-workspace", issue_comment_create_request=comment_request - ) - assert result["success"] is True - - # Test delete_work_item_comment - result = await method_executor.execute( - "comments", "delete", comment_id="comment-123", issue_id="issue-123", project_id="project-123", slug="test-workspace" - ) - assert result["success"] is True - - # Test list_work_item_comments with pagination - result = await method_executor.execute( - "comments", - "list", - issue_id="issue-123", - project_id="project-123", - slug="test-workspace", - cursor="cursor-123", - expand="actor", - fields="comment_html,created_at", - order_by="-created_at", - per_page=20, - ) - assert result["success"] is True - - # Test retrieve_work_item_comment - result = await method_executor.execute( - "comments", "retrieve", comment_id="comment-123", issue_id="issue-123", project_id="project-123", slug="test-workspace" - ) - assert result["success"] is True - - # Test update_work_item_comment with request object - update_data = {"comment_html": "

Updated comment

"} - result = await method_executor.execute( - "comments", - "update", - comment_id="comment-123", - issue_id="issue-123", - project_id="project-123", - slug="test-workspace", - patched_issue_comment_create_request=update_data, - ) - assert result["success"] is True - - @pytest.mark.asyncio - async def test_links_api_method_signatures(self, method_executor): - """Test Work Item Links API method signatures match SDK specifications.""" - - # Test create_work_item_link with request object - link_request = {"url": "https://example.com/reference", "title": "External Reference"} - result = await method_executor.execute( - "links", "create", issue_id="issue-123", project_id="project-123", slug="test-workspace", issue_link_create_request=link_request - ) - assert result["success"] is True - - # Test delete_work_item_link - result = await method_executor.execute( - "links", "delete", link_id="link-123", issue_id="issue-123", project_id="project-123", slug="test-workspace" - ) - assert result["success"] is True - - # Test list_work_item_links with pagination - result = await method_executor.execute( - "links", - "list", - issue_id="issue-123", - project_id="project-123", - slug="test-workspace", - cursor="cursor-123", - expand="metadata", - fields="url,title", - order_by="-created_at", - per_page=20, - ) - assert result["success"] is True - - # Test retrieve_work_item_link - result = await method_executor.execute( - "links", - "retrieve", - link_id="link-123", - issue_id="issue-123", - project_id="project-123", - slug="test-workspace", - expand="metadata", - fields="url,title", - ) - assert result["success"] is True - - # Test update_issue_link with request object - update_data = {"url": "https://updated-example.com", "title": "Updated Reference"} - result = await method_executor.execute( - "links", - "update", - link_id="link-123", - issue_id="issue-123", - project_id="project-123", - slug="test-workspace", - patched_issue_link_update_request=update_data, - ) - assert result["success"] is True - - @pytest.mark.asyncio - async def test_properties_api_method_signatures(self, method_executor): - """Test Work Item Properties API method signatures match SDK specifications.""" - - # Test create_issue_property with request object - property_request = {"name": "Priority Level", "description": "Custom priority field"} - result = await method_executor.execute( - "properties", "create", project_id="project-123", slug="test-workspace", type_id="type-123", issue_property_api_request=property_request - ) - assert result["success"] is True - - # Test create_issue_property_option with request object - option_request = {"name": "High Priority", "value": "high"} - result = await method_executor.execute( - "properties", - "create_option", - project_id="project-123", - property_id="property-123", - slug="test-workspace", - type_id="type-123", - issue_property_option_api_request=option_request, - ) - assert result["success"] is True - - # Test create_issue_property_value with request object - value_request = {"value": "high"} - result = await method_executor.execute( - "properties", - "create_value", - issue_id="issue-123", - project_id="project-123", - property_id="property-123", - slug="test-workspace", - type_id="type-123", - issue_property_value_api_request=value_request, - ) - assert result["success"] is True - - # Test list_issue_properties - result = await method_executor.execute("properties", "list", project_id="project-123", slug="test-workspace", type_id="type-123") - assert result["success"] is True - - # Test retrieve_issue_property - result = await method_executor.execute( - "properties", "retrieve", project_id="project-123", property_id="property-123", slug="test-workspace", type_id="type-123" - ) - assert result["success"] is True - - # Test update_issue_property with request object - update_data = {"name": "Updated Priority", "description": "Updated description"} - result = await method_executor.execute( - "properties", - "update", - project_id="project-123", - property_id="property-123", - slug="test-workspace", - type_id="type-123", - patched_issue_property_api_request=update_data, - ) - assert result["success"] is True - - # Test delete_issue_property - result = await method_executor.execute( - "properties", "delete", project_id="project-123", property_id="property-123", slug="test-workspace", type_id="type-123" - ) - assert result["success"] is True - - # Test delete_issue_property_option - result = await method_executor.execute( - "properties", - "delete_option", - option_id="option-123", - project_id="project-123", - property_id="property-123", - slug="test-workspace", - type_id="type-123", - ) - assert result["success"] is True - - # Test list_issue_property_options - result = await method_executor.execute( - "properties", "list_options", project_id="project-123", property_id="property-123", slug="test-workspace", type_id="type-123" - ) - assert result["success"] is True - - # Test list_issue_property_values - result = await method_executor.execute( - "properties", "list_values", issue_id="issue-123", project_id="project-123", slug="test-workspace", type_id="type-123" - ) - assert result["success"] is True - - # Test retrieve_issue_property_option - result = await method_executor.execute( - "properties", - "retrieve_option", - option_id="option-123", - project_id="project-123", - property_id="property-123", - slug="test-workspace", - type_id="type-123", - ) - assert result["success"] is True - - # Test update_issue_property_option with request object - update_data = {"name": "Critical Priority", "value": "critical"} - result = await method_executor.execute( - "properties", - "update_option", - option_id="option-123", - project_id="project-123", - property_id="property-123", - slug="test-workspace", - type_id="type-123", - patched_issue_property_option_api_request=update_data, - ) - assert result["success"] is True - - @pytest.mark.asyncio - async def test_types_api_method_signatures(self, method_executor): - """Test Work Item Types API method signatures match SDK specifications.""" - - # Test create_issue_type with request object - type_request = {"name": "Epic", "description": "Large feature or initiative"} - result = await method_executor.execute( - "types", "create", project_id="project-123", slug="test-workspace", issue_type_api_request=type_request - ) - assert result["success"] is True - - # Test delete_issue_type - result = await method_executor.execute("types", "delete", project_id="project-123", slug="test-workspace", type_id="type-123") - assert result["success"] is True - - # Test list_issue_types - result = await method_executor.execute("types", "list", project_id="project-123", slug="test-workspace") - assert result["success"] is True - - # Test retrieve_issue_type - result = await method_executor.execute("types", "retrieve", project_id="project-123", slug="test-workspace", type_id="type-123") - assert result["success"] is True - - # Test update_issue_type with request object - update_data = {"name": "User Story", "description": "Updated description"} - result = await method_executor.execute( - "types", "update", project_id="project-123", slug="test-workspace", type_id="type-123", patched_issue_type_api_request=update_data - ) - assert result["success"] is True - - @pytest.mark.asyncio - async def test_worklogs_api_method_signatures(self, method_executor): - """Test Work Item Worklogs API method signatures match SDK specifications.""" - - # Test create_issue_worklog with request object - worklog_request = { - "description": "Worked on feature implementation", - "duration": 120, # 2 hours in minutes - } - result = await method_executor.execute( - "worklogs", "create", issue_id="issue-123", project_id="project-123", slug="test-workspace", issue_work_log_api_request=worklog_request - ) - assert result["success"] is True - - # Test delete_issue_worklog - result = await method_executor.execute("worklogs", "delete", project_id="project-123", slug="test-workspace", worklog_id="worklog-123") - assert result["success"] is True - - # Test get_project_worklog_summary - result = await method_executor.execute("worklogs", "get_summary", project_id="project-123", slug="test-workspace") - assert result["success"] is True - - # Test list_issue_worklogs - result = await method_executor.execute("worklogs", "list", issue_id="issue-123", project_id="project-123", slug="test-workspace") - assert result["success"] is True - - # Test update_issue_worklog with request object - update_data = { - "description": "Updated work description", - "duration": 180, # 3 hours in minutes - } - result = await method_executor.execute( - "worklogs", - "update", - project_id="project-123", - slug="test-workspace", - worklog_id="worklog-123", - patched_issue_work_log_api_request=update_data, - ) - assert result["success"] is True - - def test_all_81_methods_available(self, method_executor): - """Verify that all 81 methods are available through method_executor.""" - - # Get available methods for each category (including all new categories) - categories = [ - "projects", - "workitems", - "cycles", - "labels", - "states", - "modules", - "assets", - "users", - "intake", - "members", - "activity", - "attachments", - "comments", - "links", - "properties", - "types", - "worklogs", - ] - total_methods = 0 - - for category in categories: - methods = method_executor.get_category_methods(category) - total_methods += len(methods) - assert len(methods) > 0, f"No methods found for category {category}" - - # We implemented 81 methods total (36 original + 45 new), should have at least that many - assert total_methods >= 81, f"Expected at least 81 methods, got {total_methods}" - - def test_sdk_compliance_documentation(self): - """Document that all implemented methods are SDK compliant.""" - - # This test documents our SDK compliance achievement - sdk_compliant_methods = [ - # Projects API (4 methods) - "projects.retrieve", - "projects.update", - "projects.archive", - "projects.unarchive", - # Work Items API (4 methods) - "workitems.list", - "workitems.retrieve", - "workitems.search", - "workitems.get_workspace", - # Cycles API (10 methods) - "cycles.retrieve", - "cycles.update", - "cycles.archive", - "cycles.unarchive", - "cycles.list_archived", - "cycles.add_work_items", - "cycles.list_work_items", - "cycles.retrieve_work_item", - "cycles.remove_work_item", - "cycles.transfer_work_items", - # Labels API (2 methods) - "labels.retrieve", - "labels.update", - # States API (2 methods) - "states.retrieve", - "states.update", - # Modules API (8 methods) - "modules.retrieve", - "modules.update", - "modules.archive", - "modules.unarchive", - "modules.list_archived", - "modules.add_work_items", - "modules.list_work_items", - "modules.remove_work_item", - # Assets API (5 methods) - "assets.create_user_upload", - "assets.get_generic", - "assets.update_generic", - "assets.update_user", - "assets.delete_user", - # Users API (1 method) - "users.get_current", - # Intake API (5 methods) - NEW - "intake.create", - "intake.delete", - "intake.list", - "intake.retrieve", - "intake.update", - # Members API (2 methods) - NEW - "members.get_project_members", - "members.get_workspace_members", - # Activity API (2 methods) - NEW - "activity.list", - "activity.retrieve", - # Attachments API (4 methods) - NEW - "attachments.create", - "attachments.delete", - "attachments.list", - "attachments.retrieve", - # Comments API (5 methods) - NEW - "comments.create", - "comments.delete", - "comments.list", - "comments.retrieve", - "comments.update", - # Links API (5 methods) - NEW - "links.create", - "links.delete", - "links.list", - "links.retrieve", - "links.update", - # Properties API (12 methods) - NEW - "properties.create", - "properties.create_option", - "properties.create_value", - "properties.delete", - "properties.delete_option", - "properties.list", - "properties.list_options", - "properties.list_values", - "properties.retrieve", - "properties.retrieve_option", - "properties.update", - "properties.update_option", - # Types API (5 methods) - NEW - "types.create", - "types.delete", - "types.list", - "types.retrieve", - "types.update", - # Worklogs API (5 methods) - NEW - "worklogs.create", - "worklogs.delete", - "worklogs.get_summary", - "worklogs.list", - "worklogs.update", - ] - - # Verify we have implemented 81 methods (36 original + 45 new) - assert len(sdk_compliant_methods) == 81, f"Expected 81 SDK compliant methods, documented {len(sdk_compliant_methods)}" - - print("✅ All 81 Plane API methods are SDK compliant and tested!") - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/apps/pi/pi/tests/services/test_pydantic_compatibility.py b/apps/pi/pi/tests/services/test_pydantic_compatibility.py deleted file mode 100644 index fce7eb1f01..0000000000 --- a/apps/pi/pi/tests/services/test_pydantic_compatibility.py +++ /dev/null @@ -1,308 +0,0 @@ -""" -Test Suite for Pydantic v1/v2 Compatibility. - -This test suite specifically validates that the pydantic compatibility fixes -in PlaneSDKAdapter work correctly by testing request object creation. -""" - -from unittest.mock import AsyncMock - -import pytest - -from pi.services.actions.plane_sdk_adapter import PlaneSDKAdapter - -# Configure pytest for async testing -pytest_plugins = ("pytest_asyncio",) - - -class TestPydanticCompatibility: - """Test class for validating pydantic v1/v2 compatibility.""" - - @pytest.fixture - def mock_sdk_apis(self): - """Create mock SDK API objects.""" - mock_intake = AsyncMock() - mock_intake.create_intake_work_item = AsyncMock(return_value={"id": "intake-123"}) - mock_intake.update_intake_work_item = AsyncMock(return_value={"id": "intake-123"}) - - mock_attachments = AsyncMock() - mock_attachments.create_work_item_attachment = AsyncMock(return_value={"id": "attachment-123"}) - - mock_comments = AsyncMock() - mock_comments.create_work_item_comment = AsyncMock(return_value={"id": "comment-123"}) - mock_comments.update_work_item_comment = AsyncMock(return_value={"id": "comment-123"}) - - mock_links = AsyncMock() - mock_links.create_work_item_link = AsyncMock(return_value={"id": "link-123"}) - mock_links.update_issue_link = AsyncMock(return_value={"id": "link-123"}) - - mock_properties = AsyncMock() - mock_properties.create_issue_property = AsyncMock(return_value={"id": "property-123"}) - mock_properties.update_issue_property = AsyncMock(return_value={"id": "property-123"}) - - mock_types = AsyncMock() - mock_types.create_issue_type = AsyncMock(return_value={"id": "type-123"}) - mock_types.update_issue_type = AsyncMock(return_value={"id": "type-123"}) - - mock_worklogs = AsyncMock() - mock_worklogs.create_issue_worklog = AsyncMock(return_value={"id": "worklog-123"}) - mock_worklogs.update_issue_worklog = AsyncMock(return_value={"id": "worklog-123"}) - - return { - "intake": mock_intake, - "work_item_attachments": mock_attachments, - "work_item_comments": mock_comments, - "work_item_links": mock_links, - "work_item_properties": mock_properties, - "work_item_types": mock_types, - "work_item_worklogs": mock_worklogs, - } - - @pytest.fixture - def sdk_adapter_with_mock_apis(self, mock_sdk_apis): - """Create a PlaneSDKAdapter with mocked SDK APIs.""" - adapter = PlaneSDKAdapter(access_token="test-token") - - # Replace the SDK API objects with mocks - adapter.intake = mock_sdk_apis["intake"] - adapter.work_item_attachments = mock_sdk_apis["work_item_attachments"] - adapter.work_item_comments = mock_sdk_apis["work_item_comments"] - adapter.work_item_links = mock_sdk_apis["work_item_links"] - adapter.work_item_properties = mock_sdk_apis["work_item_properties"] - adapter.work_item_types = mock_sdk_apis["work_item_types"] - adapter.work_item_worklogs = mock_sdk_apis["work_item_worklogs"] - - return adapter - - @pytest.mark.asyncio - async def test_intake_create_request_object_creation(self, sdk_adapter_with_mock_apis): - """Test that intake create method properly creates request objects.""" - adapter = sdk_adapter_with_mock_apis - - # Call the method with kwargs - need to provide the correct nested structure - result = await adapter.create_intake_work_item( - workspace_slug="test-workspace", - project_id="project-123", - issue={"name": "Test Intake", "description_html": "

Test description

", "priority": "medium"}, - intake="test-intake-id", - ) - - assert result["id"] == "intake-123" - - # Verify the SDK method was called with the correct request object - adapter.intake.create_intake_work_item.assert_called_once() - call_args = adapter.intake.create_intake_work_item.call_args - - # Check that the request object was created and passed correctly - assert call_args[1]["project_id"] == "project-123" - assert call_args[1]["slug"] == "test-workspace" - assert "intake_issue_create_request" in call_args[1] - - # The request object should be an instance of IntakeIssueCreateRequest - request_obj = call_args[1]["intake_issue_create_request"] - assert hasattr(request_obj, "name") - assert hasattr(request_obj, "description_html") - assert hasattr(request_obj, "priority") - - @pytest.mark.asyncio - async def test_intake_update_request_object_creation(self, sdk_adapter_with_mock_apis): - """Test that intake update method properly creates patched request objects.""" - adapter = sdk_adapter_with_mock_apis - - # Call the method with kwargs - result = await adapter.update_intake_work_item( - workspace_slug="test-workspace", project_id="project-123", intake_id="intake-123", name="Updated Intake", priority="high" - ) - - assert result["id"] == "intake-123" - - # Verify the SDK method was called with the correct patched request object - adapter.intake.update_intake_work_item.assert_called_once() - call_args = adapter.intake.update_intake_work_item.call_args - - # Check that the patched request object was created and passed correctly - assert call_args[1]["pk"] == "intake-123" - assert call_args[1]["project_id"] == "project-123" - assert call_args[1]["slug"] == "test-workspace" - assert "patched_intake_issue_update_request" in call_args[1] - - # The request object should be an instance of PatchedIntakeIssueUpdateRequest - request_obj = call_args[1]["patched_intake_issue_update_request"] - assert hasattr(request_obj, "name") - assert hasattr(request_obj, "priority") - - @pytest.mark.asyncio - async def test_attachments_create_request_object_creation(self, sdk_adapter_with_mock_apis): - """Test that attachments create method properly creates request objects.""" - adapter = sdk_adapter_with_mock_apis - - # Call the method with kwargs - need to provide required fields - result = await adapter.create_work_item_attachment( - workspace_slug="test-workspace", project_id="project-123", issue_id="issue-123", name="test-file.pdf", size=1024 - ) - - assert result["id"] == "attachment-123" - - # Verify the SDK method was called with the correct request object - adapter.work_item_attachments.create_work_item_attachment.assert_called_once() - call_args = adapter.work_item_attachments.create_work_item_attachment.call_args - - # Check that the request object was created and passed correctly - assert call_args[1]["project_id"] == "project-123" - assert call_args[1]["slug"] == "test-workspace" - assert "issue_attachment_upload_request" in call_args[1] - - # The request object should be an instance of IssueAttachmentUploadRequest - request_obj = call_args[1]["issue_attachment_upload_request"] - assert hasattr(request_obj, "name") - assert hasattr(request_obj, "size") - - @pytest.mark.asyncio - async def test_comments_create_request_object_creation(self, sdk_adapter_with_mock_apis): - """Test that comments create method properly creates request objects.""" - adapter = sdk_adapter_with_mock_apis - - # Call the method with kwargs - result = await adapter.create_work_item_comment( - workspace_slug="test-workspace", project_id="project-123", issue_id="issue-123", comment_html="

Test comment

" - ) - - assert result["id"] == "comment-123" - - # Verify the SDK method was called with the correct request object - adapter.work_item_comments.create_work_item_comment.assert_called_once() - call_args = adapter.work_item_comments.create_work_item_comment.call_args - - # Check that the request object was created and passed correctly - assert call_args[1]["project_id"] == "project-123" - assert call_args[1]["slug"] == "test-workspace" - assert "issue_comment_create_request" in call_args[1] - - # The request object should be an instance of IssueCommentCreateRequest - request_obj = call_args[1]["issue_comment_create_request"] - assert hasattr(request_obj, "comment_html") - - @pytest.mark.asyncio - async def test_comments_update_request_object_creation(self, sdk_adapter_with_mock_apis): - """Test that comments update method properly creates patched request objects.""" - adapter = sdk_adapter_with_mock_apis - - # Call the method with kwargs - result = await adapter.update_work_item_comment( - workspace_slug="test-workspace", project_id="project-123", comment_id="comment-123", comment_html="

Updated comment

" - ) - - assert result["id"] == "comment-123" - - # Verify the SDK method was called with the correct patched request object - adapter.work_item_comments.update_work_item_comment.assert_called_once() - call_args = adapter.work_item_comments.update_work_item_comment.call_args - - # Check that the patched request object was created and passed correctly - assert call_args[1]["pk"] == "comment-123" - assert call_args[1]["project_id"] == "project-123" - assert call_args[1]["slug"] == "test-workspace" - assert "patched_issue_comment_create_request" in call_args[1] - - # The request object should be an instance of PatchedIssueCommentCreateRequest - request_obj = call_args[1]["patched_issue_comment_create_request"] - assert hasattr(request_obj, "comment_html") - - @pytest.mark.asyncio - async def test_worklogs_create_request_object_creation(self, sdk_adapter_with_mock_apis): - """Test that worklogs create method properly creates request objects.""" - adapter = sdk_adapter_with_mock_apis - - # Call the method with kwargs - result = await adapter.create_issue_worklog( - workspace_slug="test-workspace", project_id="project-123", issue_id="issue-123", description="Worked on feature", duration=120 - ) - - assert result["id"] == "worklog-123" - - # Verify the SDK method was called with the correct request object - adapter.work_item_worklogs.create_issue_worklog.assert_called_once() - call_args = adapter.work_item_worklogs.create_issue_worklog.call_args - - # Check that the request object was created and passed correctly - assert call_args[1]["project_id"] == "project-123" - assert call_args[1]["slug"] == "test-workspace" - assert "issue_work_log_api_request" in call_args[1] - - # The request object should be an instance of IssueWorkLogAPIRequest - request_obj = call_args[1]["issue_work_log_api_request"] - assert hasattr(request_obj, "description") - assert hasattr(request_obj, "duration") - - @pytest.mark.asyncio - async def test_all_request_object_types_covered(self, sdk_adapter_with_mock_apis): - """Test that all the new request object types are properly handled.""" - adapter = sdk_adapter_with_mock_apis - - # Test all the different request object types - test_cases = [ - # (method, kwargs, expected_request_param) - ( - adapter.create_work_item_link, - {"workspace_slug": "test-workspace", "project_id": "project-123", "issue_id": "issue-123", "url": "https://example.com"}, - "issue_link_create_request", - ), - ( - adapter.update_issue_link, - {"workspace_slug": "test-workspace", "project_id": "project-123", "link_id": "link-123", "url": "https://updated.com"}, - "patched_issue_link_update_request", - ), - ( - adapter.create_issue_property, - {"workspace_slug": "test-workspace", "project_id": "project-123", "name": "Priority", "description": "Custom priority field"}, - "issue_property_api_request", - ), - ( - adapter.update_issue_property, - {"workspace_slug": "test-workspace", "project_id": "project-123", "property_id": "property-123", "name": "Updated Priority"}, - "patched_issue_property_api_request", - ), - ( - adapter.create_issue_type, - {"workspace_slug": "test-workspace", "project_id": "project-123", "name": "Bug", "description": "Bug report type"}, - "issue_type_api_request", - ), - ( - adapter.update_issue_type, - {"workspace_slug": "test-workspace", "project_id": "project-123", "type_id": "type-123", "name": "Updated Bug"}, - "patched_issue_type_api_request", - ), - ( - adapter.update_issue_worklog, - { - "workspace_slug": "test-workspace", - "project_id": "project-123", - "worklog_id": "worklog-123", - "description": "Updated work", - "duration": 180, - }, - "patched_issue_work_log_api_request", - ), - ] - - for method, kwargs, expected_request_param in test_cases: - # Call the method - result = await method(**kwargs) - - # Verify the result is successful - assert result is not None - - # Get the mock call - mock_call = method.call_args - assert mock_call is not None - - # Verify the request object parameter exists - assert expected_request_param in mock_call[1], f"Expected {expected_request_param} in call args for {method.__name__}" - - # Verify the request object has the expected attributes - request_obj = mock_call[1][expected_request_param] - assert request_obj is not None, f"Request object should not be None for {method.__name__}" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/apps/pi/pi/tests/verify_lazy_llm.py b/apps/pi/pi/tests/verify_lazy_llm.py new file mode 100644 index 0000000000..a5aa9e6bfe --- /dev/null +++ b/apps/pi/pi/tests/verify_lazy_llm.py @@ -0,0 +1,79 @@ +import asyncio +import os +import sys + +# Ensure we can import from the app +sys.path.append(os.getcwd()) +# load_dotenv() + +from pi.services.llm import llms + + +async def main(): + print("Verifying LazyLLM implementation...") + + # Access the global llm instance + lazy_instance = llms.llm + + print(f"Global llm type: {type(lazy_instance)}") + print(f"Is instance of LazyLLM? {isinstance(lazy_instance, llms.LazyLLM)}") + + # Check internal state BEFORE access + if hasattr(lazy_instance, "_proxy_target"): + print(f"Internal _proxy_target before access: {lazy_instance._proxy_target}") + if lazy_instance._proxy_target is None: + print("SUCCESS: LLM is not yet initialized.") + else: + print("FAILURE: LLM is already initialized!") + else: + print("FAILURE: LazyLLM structure not found.") + + # Trigger initialization + print("\nTriggering initialization via attribute access...") + _ = lazy_instance.model_name + + # Check internal state AFTER access + print(f"Internal _proxy_target after access: {lazy_instance._proxy_target}") + if lazy_instance._proxy_target is not None: + print("SUCCESS: LLM initialized on demand.") + else: + print("FAILURE: LLM did not initialize!") + + print("\nVerification Complete for Global LLM.") + + print("\nVerifying ChatKit.tool_llm implementation...") + from pi.services.chat.kit import ChatKit + + # Instantiate ChatKit + chat_kit = ChatKit() + lazy_tool_llm = chat_kit.tool_llm + + print(f"ChatKit.tool_llm type: {type(lazy_tool_llm)}") + print(f"Is instance of LazyLLM? {isinstance(lazy_tool_llm, llms.LazyLLM)}") + + # Check internal state BEFORE access + if hasattr(lazy_tool_llm, "_proxy_target"): + print(f"Internal _proxy_target before access: {lazy_tool_llm._proxy_target}") + if lazy_tool_llm._proxy_target is None: + print("SUCCESS: ChatKit.tool_llm is not yet initialized.") + else: + print("FAILURE: ChatKit.tool_llm is already initialized!") + else: + print("FAILURE: LazyLLM structure not found in ChatKit.tool_llm.") + + # Trigger initialization + print("\nTriggering ChatKit.tool_llm initialization via attribute access...") + _ = lazy_tool_llm.model_name + + # Check internal state AFTER access + print(f"Internal _proxy_target after access: {lazy_tool_llm._proxy_target}") + if lazy_tool_llm._proxy_target is not None: + print("SUCCESS: ChatKit.tool_llm initialized on demand.") + else: + print("FAILURE: ChatKit.tool_llm did not initialize!") + + print("\nVerification Complete.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/apps/pi/requirements.txt b/apps/pi/requirements.txt index 5e2c3cc4c0..f65d64e713 100644 --- a/apps/pi/requirements.txt +++ b/apps/pi/requirements.txt @@ -2,8 +2,8 @@ fastapi==0.111.1 requests==2.32.5 pydantic==2.8.2 -plane-sdk==0.1.10 -openai==1.77.0 +plane-sdk==0.2.0 +openai==2.8.1 html2text==2024.2.26 python-json-logger==3.3.0 sqlglot==27.2.0 @@ -16,8 +16,8 @@ boto3==1.40.16 deepgram-sdk==4.8.1 # langchain -langchain_openai==0.3.19 -langchain_community==0.3.23 +langchain_openai==1.1.0 +langchain_community==0.4.1 # sentry sentry-sdk==2.17.0 @@ -65,4 +65,4 @@ pytest==8.3.2 # markdown to html markdown-it-py==4.0.0 -mdit-py-plugins==0.5.0 \ No newline at end of file +mdit-py-plugins==0.5.0 diff --git a/apps/pi/tests/test_v1_v2_comparison.py b/apps/pi/tests/test_v1_v2_comparison.py index 9bfef9c5e6..8393cbce32 100644 --- a/apps/pi/tests/test_v1_v2_comparison.py +++ b/apps/pi/tests/test_v1_v2_comparison.py @@ -1396,7 +1396,7 @@ class APITester: v1_url=v1_create_url, v2_url=v2_create_url, status=TestStatus.PASS if error_match else TestStatus.FAIL, - message=f"Negative test: Both rejected invalid file during upload(V1: 400, V2: 400) - Error match: {'✓' if error_match else '✗'}", + message=f"Negative test: Both rejected invalid file during upload (V1: 400, V2: 400) - Error match: {'✓' if error_match else '✗'}", #noqa: E501 differences=None if error_match else {"error_messages": {"v1": v1_error_msg, "v2": v2_error_msg}}, v1_response=v1_create_data, v2_response=v2_create_data, @@ -1413,7 +1413,7 @@ class APITester: v1_url=v1_create_url, v2_url=v2_create_url, status=TestStatus.FAIL, - message=f"Inconsistent behavior during upload: V1: {v1_create_resp.status_code}, V2: {v2_create_resp.status_code}", + message=f"Inconsistent behavior during upload: V1: {v1_create_resp.status_code}, V2: {v2_create_resp.status_code}", #noqa: E501 differences={"status_mismatch": {"v1": v1_create_resp.status_code, "v2": v2_create_resp.status_code}}, v1_response=v1_create_data, v2_response=v2_create_data, diff --git a/apps/web/ee/components/pi-chat/conversation/ai-message.tsx b/apps/web/ee/components/pi-chat/conversation/ai-message.tsx index fe9633696d..13155c78e2 100644 --- a/apps/web/ee/components/pi-chat/conversation/ai-message.tsx +++ b/apps/web/ee/components/pi-chat/conversation/ai-message.tsx @@ -31,14 +31,14 @@ export const AiMessage = observer((props: TProps) => { const { getWorkspaceBySlug } = useWorkspace(); // derived const workspaceId = getWorkspaceBySlug(workspaceSlug as string)?.id; - const { query_id, answer, reasoning, isPiThinking, feedback } = dialogue || {}; + const { query_id, answer, reasoning, current_tick, isPiThinking, feedback } = dialogue || {}; return (
{/* Message */}
- {!isLoading && } + {!isLoading && } { {children}
), - th: ({ children }) => {children}, + th: ({ children }) => {children}, td: ({ children }) => {children}, }} > diff --git a/apps/web/ee/components/pi-chat/conversation/feedback.tsx b/apps/web/ee/components/pi-chat/conversation/feedback.tsx index a95ee4de5e..631d198919 100644 --- a/apps/web/ee/components/pi-chat/conversation/feedback.tsx +++ b/apps/web/ee/components/pi-chat/conversation/feedback.tsx @@ -62,7 +62,7 @@ export const Feedback = observer((props: TProps) => { }; return ( -
+
{/* Copy */} diff --git a/apps/web/ee/components/pi-chat/conversation/reasoning.tsx b/apps/web/ee/components/pi-chat/conversation/reasoning.tsx index 1a946c8278..9e5736e56d 100644 --- a/apps/web/ee/components/pi-chat/conversation/reasoning.tsx +++ b/apps/web/ee/components/pi-chat/conversation/reasoning.tsx @@ -1,16 +1,19 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import Markdown from "react-markdown"; import { Brain, ChevronDownIcon } from "lucide-react"; import { cn } from "@plane/utils"; +const stripEmojis = (str: string) => str.replace(/\p{Emoji}/gu, ""); + type TProps = { - reasoning: string | undefined; + currentTick?: string; + reasoning?: string; isThinking: boolean | undefined; }; export const ReasoningBlock = (props: TProps) => { - const { reasoning, isThinking } = props; - const [isOpen, setIsOpen] = useState(true); + const { currentTick, reasoning, isThinking } = props; + const [isOpen, setIsOpen] = useState(false); return (
@@ -37,11 +40,14 @@ export const ReasoningBlock = (props: TProps) => { onClick={() => setIsOpen(!isOpen)} aria-expanded={isOpen} className={cn( - "flex items-center gap-2 w-full px-3 transition-all duration-500 ease-in-out hover:border-transparent py-2" + "flex justify-start items-center gap-2 w-full px-3 transition-all duration-500 ease-in-out hover:border-transparent py-2 max-w-full overflow-hidden" )} >
-
Thinking
+
{stripEmojis(currentTick || "Thinking")}
{" "} + )} @@ -50,7 +56,7 @@ export const ReasoningBlock = (props: TProps) => { "overflow-hidden", "transition-all duration-500 ease-in-out", - isOpen ? "max-h-screen opacity-100" : "max-h-0 opacity-0 mt-0" + isOpen ? "max-h-fit opacity-100" : "max-h-0 opacity-0 mt-0" )} >
diff --git a/apps/web/ee/components/pi-chat/converse/voice-input.tsx b/apps/web/ee/components/pi-chat/converse/voice-input.tsx index ceafae868f..da3b860b77 100644 --- a/apps/web/ee/components/pi-chat/converse/voice-input.tsx +++ b/apps/web/ee/components/pi-chat/converse/voice-input.tsx @@ -19,14 +19,25 @@ type TProps = { isFullScreen: boolean; focus: TFocus; loader: TPiLoaders; + mode: string; setLoader: Dispatch>; - createNewChat: (focus: TFocus, isProjectLevel: boolean, workspaceId: string) => Promise; + createNewChat: (focus: TFocus, mode: string, isProjectLevel: boolean, workspaceId: string) => Promise; }; const piChatService = new PiChatService(); const AudioRecorder = (props: TProps) => { - const { workspaceId, chatId, editorRef, createNewChat, isProjectLevel, isFullScreen, focus, loader, setLoader } = - props; + const { + workspaceId, + chatId, + editorRef, + createNewChat, + isProjectLevel, + isFullScreen, + focus, + loader, + setLoader, + mode, + } = props; const [isSubmitting, setIsSubmitting] = useState(false); const [waveformData, setWaveformData] = useState<{ index: number; amplitude: number }[]>([]); @@ -111,7 +122,7 @@ const AudioRecorder = (props: TProps) => { try { setLoader("transcribing"); let chatIdToUse = chatId; - if (!chatIdToUse) chatIdToUse = await createNewChat(focus, isProjectLevel, workspaceId); + if (!chatIdToUse) chatIdToUse = await createNewChat(focus, mode, isProjectLevel, workspaceId); const response = await piChatService.transcribeAudio(workspaceId, formData, chatIdToUse); editorRef.current?.appendText(" " + response); } catch (err) { diff --git a/apps/web/ee/components/pi-chat/input/dnd-wrapper.tsx b/apps/web/ee/components/pi-chat/input/dnd-wrapper.tsx index ff80e3d487..772ae3948a 100644 --- a/apps/web/ee/components/pi-chat/input/dnd-wrapper.tsx +++ b/apps/web/ee/components/pi-chat/input/dnd-wrapper.tsx @@ -24,7 +24,8 @@ type Props = { chatId: string | undefined; isProjectLevel: boolean; focus: TFocus; - createNewChat: (focus: TFocus, isProjectLevel: boolean, workspaceId: string) => Promise; + mode: string; + createNewChat: (focus: TFocus, mode: string, isProjectLevel: boolean, workspaceId: string) => Promise; setAttachments: Dispatch>; children: (isUploading: boolean, open: () => void) => React.ReactNode; }; @@ -39,6 +40,7 @@ export const DndWrapper: FC = observer((props) => { isProjectLevel, createNewChat, focus, + mode, children, } = props; @@ -62,7 +64,7 @@ export const DndWrapper: FC = observer((props) => { if (rejectedFiles.length === 0) { setIsUploading(true); let chatIdToUse = chatId; - if (!chatIdToUse) chatIdToUse = await createNewChat(focus, isProjectLevel, workspaceId); + if (!chatIdToUse) chatIdToUse = await createNewChat(focus, mode, isProjectLevel, workspaceId); for (const file of acceptedFiles) { const currentFile: File = file; if (!currentFile) return; diff --git a/apps/web/ee/components/pi-chat/input/focus-filter.tsx b/apps/web/ee/components/pi-chat/input/focus-filter.tsx index e420634adf..03d87d6f1e 100644 --- a/apps/web/ee/components/pi-chat/input/focus-filter.tsx +++ b/apps/web/ee/components/pi-chat/input/focus-filter.tsx @@ -81,7 +81,7 @@ export const FocusFilter = observer((props: TProps) => { className="ml-4 max-w-[200px] font-medium text-custom-text-300" disabled={focus.isInWorkspaceContext} > -
+
{!isEmpty(focus) && !isEmpty(selectedFocus) && focus.isInWorkspaceContext ? (
{focus.entityType === "workspace_id" ? ( @@ -96,8 +96,8 @@ export const FocusFilter = observer((props: TProps) => { {selectedFocus?.name}
) : ( -
- Add context +
+ Add context
)}
@@ -114,7 +114,7 @@ export const FocusFilter = observer((props: TProps) => { maxHeight="lg" className="flex flex-col-reverse" buttonClassName={cn( - "rounded-md h-full px-2 border-[0.5px] border-custom-border-200 max-h-[30px] overflow-hidden max-w-[200px] hover:bg-custom-background-100" + "rounded-lg h-full px-2 border-[0.5px] border-custom-border-200 max-h-[30px] overflow-hidden max-w-[200px] hover:bg-custom-background-100" )} optionsClassName="max-h-[70vh] overflow-y-auto" > diff --git a/apps/web/ee/components/pi-chat/input/mode.tsx b/apps/web/ee/components/pi-chat/input/mode.tsx new file mode 100644 index 0000000000..2a7b7378bd --- /dev/null +++ b/apps/web/ee/components/pi-chat/input/mode.tsx @@ -0,0 +1,61 @@ +import { AiSearchIcon, AiWriteIcon } from "@plane/propel/icons"; +import { CustomSelect, Tooltip } from "@plane/ui"; +import { cn } from "@plane/utils"; + +const MODE_OPTIONS = [ + { + label: "Ask", + value: "ask", + icon: , + }, + + { + label: "Build", + value: "build", + icon: , + }, +]; +export const AiMode = (props: { aiMode: string; setAiMode: (mode: string) => void }) => { + const { aiMode, setAiMode } = props; + const selectedMode = MODE_OPTIONS.find((option) => option.value === aiMode); + return ( + +
+ {selectedMode?.icon} + {selectedMode?.label} +
+ + } + noChevron + onChange={(val: string) => { + setAiMode(val); + }} + maxHeight="lg" + className="flex flex-col-reverse" + buttonClassName={cn( + "rounded-lg h-full px-2 border-[0.5px] border-custom-border-200 h-[27px] overflow-hidden hover:bg-custom-background-100" + )} + optionsClassName="max-h-[70vh] overflow-y-auto" + > +
+
+ {MODE_OPTIONS.map((option) => ( + +
+ {option.icon} + {option.label} +
+
+ ))} +
+
+
+ ); +}; diff --git a/apps/web/ee/components/pi-chat/input/root.tsx b/apps/web/ee/components/pi-chat/input/root.tsx index 3baaaaee33..73122e7266 100644 --- a/apps/web/ee/components/pi-chat/input/root.tsx +++ b/apps/web/ee/components/pi-chat/input/root.tsx @@ -24,6 +24,7 @@ import { formatSearchQuery } from "../helper"; import { InputPreviewUploads } from "../uploads/input-preview-uploads"; import { DndWrapper } from "./dnd-wrapper"; import { FocusFilter } from "./focus-filter"; +import { AiMode } from "./mode"; import { AttachmentActionButton } from "./quick-action-button"; type TEditCommands = { @@ -61,6 +62,7 @@ export const InputBox = observer((props: TProps) => { getChatFocus, fetchModels, abortStream, + getChatMode, attachmentStore: { getAttachmentsUploadStatusByChatId }, } = usePiChat(); const { getWorkspaceBySlug } = useWorkspace(); @@ -76,6 +78,7 @@ export const InputBox = observer((props: TProps) => { const projectDetails = getProjectByIdentifier(projectIdentifier); const projectIdToUse = projectDetails?.id || projectId || ""; const chatFocus = getChatFocus(activeChatId); + const chatMode = getChatMode(activeChatId || ""); const attachmentsUploadStatus = getAttachmentsUploadStatusByChatId(activeChatId || ""); // state const [focus, setFocus] = useState( @@ -87,6 +90,7 @@ export const InputBox = observer((props: TProps) => { ); const [loader, setLoader] = useState(""); const [attachments, setAttachments] = useState([]); + const [aiMode, setAiMode] = useState(chatMode); const [isEditorReady, setIsEditorReady] = useState(false); //ref const editorCommands = useRef(null); @@ -126,7 +130,7 @@ export const InputBox = observer((props: TProps) => { let chatIdToUse = activeChatId; setLoader("submitting"); if (!chatIdToUse) { - chatIdToUse = await createNewChat(focus, isProjectLevel, workspaceId); + chatIdToUse = await createNewChat(focus, aiMode, isProjectLevel, workspaceId); } // Don't redirect if we are in the floating chat window if (shouldRedirect && !routeChatId) @@ -142,7 +146,8 @@ export const InputBox = observer((props: TProps) => { workspaceSlug?.toString(), workspaceId, pathname, - attachmentIds + attachmentIds, + aiMode ); editorCommands.current?.clear(); addContext(); @@ -169,6 +174,9 @@ export const InputBox = observer((props: TProps) => { }; setFocus(presentFocus); } + if (chatMode) { + setAiMode(chatMode); + } }, [isChatLoading, chatFocus]); // Adding context for the sidecar @@ -201,6 +209,7 @@ export const InputBox = observer((props: TProps) => { chatId={activeChatId} setAttachments={setAttachments} isProjectLevel={isProjectLevel} + mode={aiMode} createNewChat={createNewChat} focus={focus} > @@ -223,22 +232,9 @@ export const InputBox = observer((props: TProps) => { />
)} - {/* editor view */} - { - setEditorCommands({ ...command }); - }} - handleSubmit={handleSubmit} - searchCallback={getMentionSuggestions} - className={cn("flex-1 max-h-[250px] min-h-[70px]", { - "absolute w-0": SPEECH_LOADERS.includes(loader), - })} - onEditorReady={() => setIsEditorReady(true)} - ref={editorRef} - /> -
- {/* Focus */} - {!SPEECH_LOADERS.includes(loader) && ( + {/* Focus */} + {!SPEECH_LOADERS.includes(loader) && ( +
{ setFocus={setFocus} isLoading={isChatLoading && !!activeChatId} /> - )} +
+ )} + {/* editor view */} + { + setEditorCommands({ ...command }); + }} + handleSubmit={handleSubmit} + searchCallback={getMentionSuggestions} + className={cn("flex-1 max-h-[250px] min-h-[50px]", { + "absolute w-0": SPEECH_LOADERS.includes(loader), + })} + onEditorReady={() => setIsEditorReady(true)} + ref={editorRef} + /> +
+ {/* Focus */} + {!SPEECH_LOADERS.includes(loader) && }
{/* speech recorder */} @@ -265,6 +278,7 @@ export const InputBox = observer((props: TProps) => { setLoader={setLoader} isFullScreen={isFullScreen} focus={focus} + mode={aiMode} /> {!SPEECH_LOADERS.includes(loader) && ( diff --git a/apps/web/ee/components/pi-chat/system-prompts.tsx b/apps/web/ee/components/pi-chat/system-prompts.tsx index 66bb8ba5ba..80c1fe423d 100644 --- a/apps/web/ee/components/pi-chat/system-prompts.tsx +++ b/apps/web/ee/components/pi-chat/system-prompts.tsx @@ -50,7 +50,7 @@ const SystemPrompts = (props: TSystemPrompt) => { entityType: projectId ? "project_id" : "workspace_id", entityIdentifier: projectId?.toString() || workspaceId?.toString() || "", }; - const newChatId = await createNewChat(focus, isProjectLevel, workspaceId); + const newChatId = await createNewChat(focus, "ask", isProjectLevel, workspaceId); setIsInitializing(""); // Don't redirect if we are in the floating chat window if (shouldRedirect) router.push(`/${workspaceSlug}/${isProjectLevel ? "projects/" : ""}pi-chat/${newChatId}`); @@ -62,7 +62,8 @@ const SystemPrompts = (props: TSystemPrompt) => { workspaceSlug?.toString(), workspaceId?.toString(), pathname, - [] + [], + "ask" ); }; const promptIcon = getIcon(prompt.type); @@ -92,7 +93,6 @@ const SystemPrompts = (props: TSystemPrompt) => { )}
- {prompt.text} ); diff --git a/apps/web/ee/store/pi-chat/pi-chat.ts b/apps/web/ee/store/pi-chat/pi-chat.ts index 73c16f458b..a4c2ee31d9 100644 --- a/apps/web/ee/store/pi-chat/pi-chat.ts +++ b/apps/web/ee/store/pi-chat/pi-chat.ts @@ -51,6 +51,7 @@ export interface IPiChatStore { geFavoriteChats: () => TUserThreads[]; getChatById: (chatId: string) => TChatHistory; getChatFocus: (chatId: string | undefined) => TFocus | undefined; + getChatMode: (chatId: string) => string; // actions initPiChat: (chatId?: string) => void; fetchChatById: (chatId: string, workspaceId: string | undefined) => void; @@ -62,7 +63,8 @@ export interface IPiChatStore { workspaceSlug: string, workspaceId: string | undefined, callbackUrl: string, - attachmentIds: string[] + attachmentIds: string[], + aiMode: string ) => Promise; getInstance: (workspaceId: string) => Promise; fetchUserThreads: (workspaceId: string | undefined, isProjectChat: boolean) => void; @@ -78,7 +80,12 @@ export interface IPiChatStore { fetchModels: (workspaceId?: string) => Promise; abortStream: (chatId: string) => void; setActiveModel: (model: TAiModels) => void; - createNewChat: (focus: TFocus, isProjectChat: boolean, workspaceId: string | undefined) => Promise; + createNewChat: ( + focus: TFocus, + mode: string, + isProjectChat: boolean, + workspaceId: string | undefined + ) => Promise; renameChat: (chatId: string, title: string, workspaceId: string | undefined) => Promise; deleteChat: (chatId: string, workspaceSlug: string) => Promise; favoriteChat: (chatId: string, workspaceId: string | undefined) => Promise; @@ -243,6 +250,14 @@ export class PiChatStore implements IPiChatStore { } }); + getChatMode = computedFn((chatId: string) => { + const chat = this.chatMap[chatId]; + if (chat) { + return chat.mode || "ask"; + } + return "ask"; + }); + getGroupedArtifactsByDialogue = computedFn((chatId: string, messageId: string) => { const dialogue = this.chatMap[chatId]?.dialogueMap[messageId]; const response: { @@ -294,7 +309,12 @@ export class PiChatStore implements IPiChatStore { this.isPiArtifactsDrawerOpen = undefined; }; - createNewChat = async (focus: TFocus, isProjectChat: boolean = false, workspaceId: string | undefined) => { + createNewChat = async ( + focus: TFocus, + mode: string, + isProjectChat: boolean = false, + workspaceId: string | undefined + ) => { this.isNewChat = true; let payload: TInitPayload = { workspace_in_context: focus.isInWorkspaceContext, @@ -323,6 +343,7 @@ export class PiChatStore implements IPiChatStore { focus_workspace_id: focus.entityType === "workspace_id" ? focus.entityIdentifier : "", focus_project_id: focus.entityType === "project_id" ? focus.entityIdentifier : "", workspace_id: workspaceId, + mode, }; if (isProjectChat) { @@ -343,7 +364,8 @@ export class PiChatStore implements IPiChatStore { workspaceId: string | undefined, callbackUrl: string | undefined, attachmentIds: string[], - isNewChat: boolean + isNewChat: boolean, + mode: string ) => { let payload: TQuery = { chat_id: chatId, @@ -364,6 +386,7 @@ export class PiChatStore implements IPiChatStore { workspace_slug: workspaceSlug, workspace_id: workspaceId, attachment_ids: attachmentIds, + mode, }; if (focus.isInWorkspaceContext) { payload = { ...payload, [focus.entityType]: focus.entityIdentifier }; @@ -410,7 +433,9 @@ export class PiChatStore implements IPiChatStore { eventSource.addEventListener("reasoning", (event: MessageEvent) => { try { const data = JSON.parse(event.data); - newDialogue.reasoning += data.reasoning; + if (!data.header) return; + newDialogue.current_tick = data.header; + newDialogue.reasoning = newDialogue.reasoning + `${data.header}${data.content}`; this.updateDialogue(chatId, token, newDialogue); } catch (e) { console.error("Reasoning parse error", e); @@ -478,7 +503,8 @@ export class PiChatStore implements IPiChatStore { workspaceSlug: string, workspaceId: string | undefined, callbackUrl: string, - attachmentIds: string[] + attachmentIds: string[], + aiMode: string ) => { if (!chatId) { throw new Error("Chat not initialized"); @@ -489,6 +515,7 @@ export class PiChatStore implements IPiChatStore { query, llm: this.activeModel?.id, answer: "", + current_tick: "", reasoning: "", isPiThinking: true, actions: [], @@ -522,7 +549,8 @@ export class PiChatStore implements IPiChatStore { workspaceId, callbackUrl, attachmentIds, - isNewChat + isNewChat, + aiMode ); this.piChatService .retrieveToken(payload) @@ -578,6 +606,7 @@ export class PiChatStore implements IPiChatStore { query: this.chatMap[chatId]?.dialogueMap?.[token]?.query, llm: this.activeModel?.id, answer: "", + current_tick: "", reasoning: "", isPiThinking: true, query_id: token, diff --git a/apps/web/ee/types/pi-chat.d.ts b/apps/web/ee/types/pi-chat.d.ts index 21cbf350fc..922d9f4dad 100644 --- a/apps/web/ee/types/pi-chat.d.ts +++ b/apps/web/ee/types/pi-chat.d.ts @@ -38,6 +38,7 @@ export type TQuery = { pi_sidebar_open?: boolean; sidebar_open_url?: string; attachment_ids?: string[]; + mode?: string; }; export type TInitPayload = Pick< TQuery, @@ -138,6 +139,7 @@ export type TDialogue = { answer?: string; llm?: string; feedback?: EFeedback; + current_tick?: string; reasoning?: string; isPiThinking: boolean; execution_status?: EExecutionStatus; @@ -163,6 +165,7 @@ export type TChatHistory = { focus_workspace_id: string; focus_project_id: string; workspace_id?: string; + mode?: string; }; export type TAction = { diff --git a/packages/propel/src/icons/ai-search.tsx b/packages/propel/src/icons/ai-search.tsx new file mode 100644 index 0000000000..025e805d91 --- /dev/null +++ b/packages/propel/src/icons/ai-search.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +import type { ISvgIcons } from "./type"; + +export const AiSearchIcon: React.FC = ({ className = "", ...rest }) => ( + + + +); diff --git a/packages/propel/src/icons/ai-write.tsx b/packages/propel/src/icons/ai-write.tsx new file mode 100644 index 0000000000..b7bea1816f --- /dev/null +++ b/packages/propel/src/icons/ai-write.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; + +import type { ISvgIcons } from "./type"; + +export const AiWriteIcon: React.FC = ({ className = "", ...rest }) => ( + + + + +); diff --git a/packages/propel/src/icons/index.ts b/packages/propel/src/icons/index.ts index 47fb485dfe..af0b98fa4f 100644 --- a/packages/propel/src/icons/index.ts +++ b/packages/propel/src/icons/index.ts @@ -5,6 +5,8 @@ export { ICON_REGISTRY } from "./registry"; export * from "./actions"; export * from "./arrows"; export * from "./ai-icon"; +export * from "./ai-search"; +export * from "./ai-write"; export * from "./approver-icon"; export * from "./at-risk-icon"; export * from "./attachments";