[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 <[email protected]>
Co-authored-by: sunder <[email protected]>
This commit is contained in:
Akshita Goyal
2025-12-05 15:11:42 +05:30
committed by GitHub
co-authored by sunder sunder
parent 2740d73003
commit fc187a883f
98 changed files with 11736 additions and 12428 deletions
-10
View File
@@ -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}
+144 -14
View File
@@ -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:
+24 -44
View File
@@ -168,57 +168,37 @@ Here is the list of available tables in Plane's database with their descriptions
{table_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 <table_analysis> 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.
"""
+9 -9
View File
@@ -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
@@ -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")
+4 -1
View File
@@ -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: <message>" (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('"')):
+99 -102
View File
@@ -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)}")
+32 -304
View File
@@ -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"),
}
}
)
@@ -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("<id of"))
)
if needs_project_id:
# Extract project_id from original artifact data
original_artifact_data = artifact.data or {}
original_project_id = _extract_project_id_from_artifact(original_artifact_data)
if original_project_id:
tool_args["project_id"] = original_project_id
else:
# No project_id found in original artifact - this might be okay for some entities
log.warning(f"No project_id found in original artifact {artifact_item.artifact_id} (entity: {entity_type})")
# Create ActionArtifactVersion and use its UUID as step_id
try:
from pi.services.actions.artifacts.utils import convert_uuids_to_strings
from pi.services.retrievers.pg_store.action_artifact import create_action_artifact_version
# Ensure all UUID objects are converted to strings for JSON serialization
serializable_tool_args = convert_uuids_to_strings(tool_args)
version = await create_action_artifact_version(
db=db,
artifact_id=artifact_item.artifact_id,
data=serializable_tool_args, # Use UUID-safe tool_args
change_type="manual_edit",
chat_id=request.chat_id,
message_id=request.message_id,
user_id=str(user_id),
)
if version:
step_id = str(version.id) # Use ActionArtifactVersion UUID as step_id
version_id = str(version.id)
log.info(f"Created ActionArtifactVersion {version.id} for edited artifact {artifact_item.artifact_id}")
else:
log.error(f"Failed to create ActionArtifactVersion for artifact {artifact_item.artifact_id}")
continue
except Exception as e:
log.error(f"Error creating ActionArtifactVersion for artifact {artifact_item.artifact_id}: {e}")
continue
log.info(f"Using edited artifact data for artifact {artifact_item.artifact_id}")
else:
# **NORMAL ARTIFACT** - Get the MessageFlowStep UUID
# Find the flow step for this artifact to get its UUID
planned_actions_for_artifact = await get_planned_actions_for_execution(request.message_id, request.chat_id, db)
# Find the planned action for this specific artifact
planned_action = None
for pa in planned_actions_for_artifact:
if pa.get("artifact_id") == str(artifact_item.artifact_id):
planned_action = pa
break
if not planned_action:
log.error(f"No planned action found for artifact {artifact_item.artifact_id}")
continue
# Use the flow step UUID as step_id
step_id_raw = planned_action.get("step_id")
if not step_id_raw or not _is_valid_uuid(str(step_id_raw)):
log.error(f"Invalid or missing step_id for artifact {artifact_item.artifact_id}: {step_id_raw}")
continue
step_id = str(step_id_raw)
# Get tool args from the planned action
tool_args = planned_action.get("args", {})
if not tool_args:
log.error(f"No tool args found for artifact {artifact_item.artifact_id}")
continue
version_id = None # Normal artifacts don't have versions
log.info(f"Using normal artifact data for artifact {artifact_item.artifact_id} with step_id {step_id}")
# Build tool name and args
tool_name = f"{entity_type}s_{original_action}"
# Validate and clean tool args
if not tool_args:
log.error(f"No tool args found for artifact {artifact_item.artifact_id}, entity: {entity_type}, action: {original_action}")
continue
args = {
**tool_args,
# Only add entity ID for update/delete actions
**(
{f"{entity_type}_id" if entity_type != "workitem" else "issue_id": str(entity_id)}
if original_action in ["update", "delete"] and entity_id
else {}
),
}
# Log the final args for debugging
log.info(f"Tool args for {tool_name}: {list(args.keys())}")
# Add to planned actions
planned_action_item = {
"step_id": step_id, # Now always a proper UUID
"step_order": len(planned_actions) + 1,
"tool_name": tool_name,
"args": args,
"action_summary": {"action": original_action, "entity": entity_type},
"tool_id": None,
"artifact_id": str(artifact_item.artifact_id),
"artifact_type": entity_type, # Add artifact type for response formatting
"sequence": len(planned_actions) + 1,
}
# Add version_id for edited artifacts
if artifact_item.is_edited and version_id:
planned_action_item["version_id"] = version_id
planned_action_item["is_edited"] = True
planned_actions.append(planned_action_item)
edit_type = "edited" if artifact_item.is_edited else "normal"
log.info(f"Added {tool_name} for artifact {artifact_item.artifact_id} ({edit_type})")
if not planned_actions:
log.error("No valid artifacts found")
return None
# Create appropriate query message based on artifact types
normal_count = sum(1 for action in planned_actions if not action.get("is_edited", False))
edited_count = sum(1 for action in planned_actions if action.get("is_edited", False))
if normal_count > 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"
@@ -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
@@ -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"]),
+177 -49
View File
@@ -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"]},
# )
+25 -29
View File
@@ -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
@@ -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("<id of"))
)
if needs_project_id:
# Extract project_id from original artifact data
original_artifact_data = artifact.data or {}
original_project_id = _extract_project_id_from_artifact(original_artifact_data)
if original_project_id:
tool_args["project_id"] = original_project_id
else:
# No project_id found in original artifact - this might be okay for some entities
log.warning(f"No project_id found in original artifact {artifact_item.artifact_id} (entity: {entity_type})")
# Resolve project_id to full project object for UI display
try:
from pi.services.actions.artifacts.utils import resolve_project_id_to_object
tool_args = await resolve_project_id_to_object(tool_args)
log.info(f"Resolved project data for artifact {artifact_item.artifact_id}")
except Exception as e:
log.error(f"Error resolving project_id for artifact {artifact_item.artifact_id}: {e}")
# Create ActionArtifactVersion and use its UUID as step_id
try:
from pi.services.actions.artifacts.utils import convert_uuids_to_strings
from pi.services.retrievers.pg_store.action_artifact import create_action_artifact_version
# Ensure all UUID objects are converted to strings for JSON serialization
serializable_tool_args = convert_uuids_to_strings(tool_args)
version = await create_action_artifact_version(
db=db,
artifact_id=artifact_item.artifact_id,
data=serializable_tool_args, # Use UUID-safe tool_args
change_type="manual_edit",
chat_id=request.chat_id,
message_id=request.message_id,
user_id=str(user_id),
)
if version:
step_id = str(version.id) # Use ActionArtifactVersion UUID as step_id
version_id = str(version.id)
log.info(f"Created ActionArtifactVersion {version.id} for edited artifact {artifact_item.artifact_id}")
else:
log.error(f"Failed to create ActionArtifactVersion for artifact {artifact_item.artifact_id}")
continue
except Exception as e:
log.error(f"Error creating ActionArtifactVersion for artifact {artifact_item.artifact_id}: {e}")
continue
log.info(f"Using edited artifact data for artifact {artifact_item.artifact_id}")
else:
# **NORMAL ARTIFACT** - Get the MessageFlowStep UUID
# Find the flow step for this artifact to get its UUID
planned_actions_for_artifact = await get_planned_actions_for_execution(request.message_id, request.chat_id, db)
# Find the planned action for this specific artifact
planned_action = None
for pa in planned_actions_for_artifact:
if pa.get("artifact_id") == str(artifact_item.artifact_id):
planned_action = pa
break
if not planned_action:
log.error(f"No planned action found for artifact {artifact_item.artifact_id}")
continue
# Use the flow step UUID as step_id
step_id_raw = planned_action.get("step_id")
if not step_id_raw or not _is_valid_uuid(str(step_id_raw)):
log.error(f"Invalid or missing step_id for artifact {artifact_item.artifact_id}: {step_id_raw}")
continue
step_id = str(step_id_raw)
# Get tool args from the planned action
tool_args = planned_action.get("args", {})
if not tool_args:
log.error(f"No tool args found for artifact {artifact_item.artifact_id}")
continue
version_id = None # Normal artifacts don't have versions
log.info(f"Using normal artifact data for artifact {artifact_item.artifact_id} with step_id {step_id}")
# Build tool name and args
tool_name = f"{entity_type}s_{original_action}"
# Validate and clean tool args
if not tool_args:
log.error(f"No tool args found for artifact {artifact_item.artifact_id}, entity: {entity_type}, action: {original_action}")
continue
args = {
**tool_args,
# Only add entity ID for update/delete actions
**(
{f"{entity_type}_id" if entity_type != "workitem" else "issue_id": str(entity_id)}
if original_action in ["update", "delete"] and entity_id
else {}
),
}
# Log the final args for debugging
log.info(f"Tool args for {tool_name}: {list(args.keys())}")
# Add to planned actions
planned_action_item = {
"step_id": step_id, # Now always a proper UUID
"step_order": len(planned_actions) + 1,
"tool_name": tool_name,
"args": args,
"action_summary": {"action": original_action, "entity": entity_type},
"tool_id": None,
"artifact_id": str(artifact_item.artifact_id),
"artifact_type": entity_type, # Add artifact type for response formatting
"sequence": len(planned_actions) + 1,
}
# Add version_id for edited artifacts
if artifact_item.is_edited and version_id:
planned_action_item["version_id"] = version_id
planned_action_item["is_edited"] = True
planned_actions.append(planned_action_item)
edit_type = "edited" if artifact_item.is_edited else "normal"
log.info(f"Added {tool_name} for artifact {artifact_item.artifact_id} ({edit_type})")
if not planned_actions:
log.error("No valid artifacts found")
return None
# Create appropriate query message based on artifact types
normal_count = sum(1 for action in planned_actions if not action.get("is_edited", False))
edited_count = sum(1 for action in planned_actions if action.get("is_edited", False))
if normal_count > 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"
+15
View File
@@ -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
+19
View File
@@ -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
+3
View File
@@ -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(
+10
View File
@@ -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
+8 -6
View File
@@ -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)
@@ -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 ""
File diff suppressed because it is too large Load Diff
+33 -21
View File
@@ -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
+15 -11
View File
@@ -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()
@@ -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,
}
@@ -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]
+22 -18
View File
@@ -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]
@@ -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]
+90 -4
View File
@@ -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]:
+66 -23
View File
@@ -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]
+55 -59
View File
@@ -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,
@@ -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,
+15 -15
View File
@@ -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]
+57 -23
View File
@@ -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]
+54 -23
View File
@@ -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]
+11 -8
View File
@@ -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 "[email protected]" 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 "[email protected]" 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]
+40 -44
View File
@@ -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
]
+13 -29
View File
@@ -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]
+101 -35
View File
@@ -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 "<id of project: X>"
# - Require a UUID-shaped project_id
try:
if "<id of" in project_id:
return PlaneToolBase.format_error_payload(
"Invalid project_id: received a placeholder. Resolve a real UUID using search_project_by_name or search_project_by_identifier before calling projects_retrieve.", # noqa E501
f"project_id={project_id}",
)
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):
return PlaneToolBase.format_error_payload(
"Invalid project_id format: expected UUID. Use search_project_by_name or search_project_by_identifier to resolve the UUID, then retry.", # noqa E501
f"project_id={project_id}",
)
except Exception:
# Best-effort validation; proceed if validation fails unexpectedly
pass
# Auto-fill workspace_slug from context if not provided
if workspace_slug is None and "workspace_slug" in context:
workspace_slug = context["workspace_slug"]
result = await method_executor.execute("projects", "retrieve", pk=pk, workspace_slug=workspace_slug)
result = await method_executor.execute("projects", "retrieve", project_id=project_id, workspace_slug=workspace_slug)
if result["success"]:
return PlaneToolBase.format_success_response("Successfully retrieved project", result["data"])
return PlaneToolBase.format_success_payload("Successfully retrieved project", result["data"])
else:
return PlaneToolBase.format_error_response("Failed to retrieve project", result["error"])
return PlaneToolBase.format_error_payload("Failed to retrieve project", result["error"])
@tool
async def projects_update(
pk: str,
project_id: str,
workspace_slug: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
@@ -198,15 +257,17 @@ def get_project_tools(method_executor, context):
issue_views_view: Optional[bool] = None,
page_view: Optional[bool] = None,
intake_view: Optional[bool] = None,
is_time_tracking_enabled: Optional[bool] = None,
is_issue_type_enabled: Optional[bool] = None,
guest_view_all_features: Optional[bool] = None,
archive_in: Optional[int] = None,
close_in: Optional[int] = None,
timezone: Optional[str] = None,
) -> 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
+170 -53
View File
@@ -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,
+57 -22
View File
@@ -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]
+67 -22
View File
@@ -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]
+7 -10
View File
@@ -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]
+190 -97
View File
@@ -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
]
+46 -21
View File
@@ -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]
+199
View File
@@ -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 "<id of" in value
elif isinstance(value, dict):
return any(self._has_placeholder(v) for v in value.values())
elif isinstance(value, list):
return any(self._has_placeholder(v) for v in value)
return False
def _are_actions_independent(self, planned_actions: List[Dict]) -> 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
+872
View File
@@ -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
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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: "<id of project: ProjectName>"
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 "<id of" in value:
return True
# 2) Non-UUID string passed to an *_id field for known entities
if isinstance(value, str) and (key.endswith("_id") or key.endswith("_ids")):
# Handle both _id and _ids
if key.endswith("_ids"):
entity_type = key[:-4] # remove _ids
else:
entity_type = key[:-3] # remove _id
# Treat issue_id as workitem for resolution purposes
if entity_type == "issue":
entity_type = "workitem"
# For single value, check if it matches UUID
if entity_type in entity_keys:
is_uuid = uuid_regex.match(value)
if not is_uuid:
return True
# 3) List of placeholders or non-UUID id values for known entities
if isinstance(value, list):
for item in value:
# Skip sentinel in lists as well (defensive)
if key == "project_id" and isinstance(item, str) and item == "__workspace_scope__":
continue
if isinstance(item, str):
if "<id of" in item:
return True
# Check for non-UUIDs in _id or _ids fields
if key.endswith("_id") or key.endswith("_ids"):
if key.endswith("_ids"):
entity_type = key[:-4]
else:
entity_type = key[:-3]
if entity_type == "issue":
entity_type = "workitem"
if entity_type in entity_keys and not uuid_regex.match(item):
return True
return False
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 = []
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 ""
File diff suppressed because it is too large Load Diff
@@ -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": "<p>text</p>"}
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
File diff suppressed because it is too large Load Diff
@@ -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."
@@ -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": "<agent_name>", "query": "<query_text>"}]}'
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
@@ -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)
@@ -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 "<id of" in value:
return True
# 2) Non-UUID string passed to an *_id field for known entities
if isinstance(value, str) and key.endswith("_id"):
entity_type = key[:-3]
is_uuid = uuid_regex.match(value)
if entity_type in entity_keys and not is_uuid:
return True
# 3) List of placeholders or non-UUID id values for known entities
if isinstance(value, list):
for item in value:
# Skip sentinel in lists as well (defensive)
if key == "project_id" and isinstance(item, str) and item == "__workspace_scope__":
continue
if isinstance(item, str) and (
"<id of" in item or (key.endswith("_id") and key[:-3] in entity_keys and not uuid_regex.match(item))
):
return True
return False
async def _resolve_placeholders(self, tool_args: Dict[str, Any], tools: List, context: BatchExecutionContext) -> 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 "<id of" in value:
match = re.search(r"<id of (\w+): ([^>]+)>", 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 "<id of" not in val and not uuid_regex.match(val):
# Skip sentinel (workspace scope)
if key == "project_id" and val == "__workspace_scope__":
continue
resolved_id = await self._resolve_entity_id(key[:-3], val, tools, context)
if resolved_id:
resolved_args[key] = resolved_id
else:
log.error(f"Failed to auto-resolve {key} for '{val}'")
return None
# Now fallback to original placeholder handling for lists and stray entries
for key, value in tool_args.items():
if isinstance(value, list):
resolved_list = []
for item in value:
if isinstance(item, str) and "<id of" in item:
match = re.search(r"<id of (\w+): ([^>]+)>", 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
@@ -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
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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"
@@ -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
@@ -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., "<id of project: Luxury Car>")
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 "<id of" in value
def _parse_placeholder(self, placeholder: str) -> Tuple[Optional[str], Optional[str]]:
"""
Parse placeholder string into entity type and name.
Args:
placeholder: Placeholder string (e.g., "<id of project: Motor Bike>")
Returns:
Tuple of (entity_type, entity_name), or (None, None) if parse fails
"""
# Pattern: '<id of {entity_type}: {entity_name}>'
match = re.match(r"<id of (\w+): (.+)>", 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"
)
@@ -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,
)
+625 -148
View File
@@ -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: "<id of project: Mattu>"` 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 `<id of workitem: ask>` 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**: `<id of entity_type: entity_name>`
- Module created in this plan: `<id of module: my-module>`
- Workitem created in this plan: `<id of workitem: bug fix>`
- Cycle created in this plan: `<id of cycle: Sprint 24>`
- Project created in this plan: `<id of project: my-project>`
- **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: '<id of cycle: Sprint 24>' CORRECT (newly created - use placeholder)
- issues: ['abc-123-uuid', 'def-456-uuid'] CORRECT (existing - use actual UUIDs from search)
- WRONG: issues: ['<id of workitem: Login Page>', '<id of workitem: Logout Page>'] (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: ['<id of workitem: Fix login bug>'] CORRECT (newly created - use placeholder)
- WRONG: module_id: '<id of module: Backend>' (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 `<id of X: name>` 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: <project name> (<uuid>)" 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 `<id of module: my-module>`
- Workitem created in this plan 'bug fix' downstream action may refer to `<id of workitem: bug fix>`
- Project created in this plan 'my-project' downstream action may refer to `<id of project: my-project>`
- 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' `<id of module: my-module>` and 'bug fix' `<id of workitem: 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 <project_identifier-workitem_sequence>, 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 "<id of" in val or not uuid_pattern.match(val):
# Placeholders like "<id of project: X>" are allowed for ACTION tools during planning
if "<id of" in val:
is_missing = not _is_action
elif not uuid_pattern.match(val):
# Non-UUID strings (e.g., names) must be resolved; still missing
is_missing = True
elif isinstance(val, dict):
# If dict provided, require a valid UUID in 'id' key
@@ -875,7 +1313,7 @@ async def handle_missing_required_fields(
chat_id: str,
tool_id: str,
current_step: int,
combined_agent_query: str,
combined_tool_query: str,
is_project_chat: Optional[bool] = None,
) -> 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)
+100 -368
View File
@@ -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")
+179 -471
View File
@@ -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 '<theme>'").
- 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 <number>` 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.
"""
+39 -27
View File
@@ -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"
# ],
+106 -8
View File
@@ -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,
+3 -3
View File
@@ -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 = ""
+51 -7
View File
@@ -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))
@@ -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
@@ -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,
@@ -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,
},
@@ -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]
]
+386 -104
View File
@@ -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
@@ -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,
)
+37 -16
View File
@@ -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
@@ -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' - <http://127.0.0.1:3000/integrations03/browse/PALL-4/>
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' - <http://127.0.0.1:3000/integrations03/browse/PALL-5/>
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}")
@@ -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"])
@@ -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": "<p>Test description</p>", "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": "<p>This is a test comment</p>"}
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": "<p>Updated comment</p>"}
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"])
@@ -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": "<p>Test description</p>", "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="<p>Test comment</p>"
)
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="<p>Updated comment</p>"
)
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"])
+79
View File
@@ -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())
+5 -5
View File
@@ -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
mdit-py-plugins==0.5.0
+2 -2
View File
@@ -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,
@@ -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 (
<div className="flex gap-4" id={id}>
<div className="flex flex-col text-base break-words w-full">
{/* Message */}
<div className="flex flex-col gap-4">
{!isLoading && <ReasoningBlock reasoning={reasoning} isThinking={isPiThinking} />}
{!isLoading && <ReasoningBlock reasoning={reasoning} currentTick={current_tick} isThinking={isPiThinking} />}
<Markdown
remarkPlugins={[remarkGfm]}
className="pi-chat-root [&>*:first-child]:mt-0 animate-fade-in"
@@ -53,7 +53,7 @@ export const AiMessage = observer((props: TProps) => {
<table className="min-w-full border-collapse">{children}</table>
</div>
),
th: ({ children }) => <th className="px-2 py-3 border-custom-border-200">{children}</th>,
th: ({ children }) => <th className="px-2 py-3 border-custom-border-200"> {children}</th>,
td: ({ children }) => <td className="px-2 py-3 border-custom-border-200">{children}</td>,
}}
>
@@ -62,7 +62,7 @@ export const Feedback = observer((props: TProps) => {
};
return (
<div className="flex gap-4 mt-6">
<div className="flex gap-4">
{/* Copy */}
<Tooltip tooltipContent="Copy to clipboard" position="bottom" className="mb-4">
<Copy size={16} onClick={handleCopyLink} className="my-auto cursor-pointer text-custom-text-300" />
@@ -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 (
<div className={cn("flex flex-col")}>
@@ -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"
)}
>
<div className="w-2 h-4 rounded-[1px] pi-cursor animate-vertical-scale" />
<div className="flex gap-2 items-center shimmer">Thinking</div>
<div className={cn("shimmer truncate")}>{stripEmojis(currentTick || "Thinking")}</div>{" "}
<ChevronDownIcon
className={`w-4 h-4 transition-transform duration-500 ease-in-out flex-shrink-0 ${isOpen ? "transform rotate-180" : ""}`}
/>
</button>
)}
@@ -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"
)}
>
<div className="mx-4 overflow-hidden text-custom-text-300 relative">
@@ -19,14 +19,25 @@ type TProps = {
isFullScreen: boolean;
focus: TFocus;
loader: TPiLoaders;
mode: string;
setLoader: Dispatch<SetStateAction<TPiLoaders>>;
createNewChat: (focus: TFocus, isProjectLevel: boolean, workspaceId: string) => Promise<string>;
createNewChat: (focus: TFocus, mode: string, isProjectLevel: boolean, workspaceId: string) => Promise<string>;
};
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) {
@@ -24,7 +24,8 @@ type Props = {
chatId: string | undefined;
isProjectLevel: boolean;
focus: TFocus;
createNewChat: (focus: TFocus, isProjectLevel: boolean, workspaceId: string) => Promise<string>;
mode: string;
createNewChat: (focus: TFocus, mode: string, isProjectLevel: boolean, workspaceId: string) => Promise<string>;
setAttachments: Dispatch<SetStateAction<TPiAttachment[]>>;
children: (isUploading: boolean, open: () => void) => React.ReactNode;
};
@@ -39,6 +40,7 @@ export const DndWrapper: FC<Props> = observer((props) => {
isProjectLevel,
createNewChat,
focus,
mode,
children,
} = props;
@@ -62,7 +64,7 @@ export const DndWrapper: FC<Props> = 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;
@@ -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}
>
<div className="flex rounded-full font-medium gap-2 w-full overflow-hidden">
<div className="flex font-medium gap-2 w-full overflow-hidden">
{!isEmpty(focus) && !isEmpty(selectedFocus) && focus.isInWorkspaceContext ? (
<div className=" flex items-center gap-2 text-sm my-auto capitalize truncate">
{focus.entityType === "workspace_id" ? (
@@ -96,8 +96,8 @@ export const FocusFilter = observer((props: TProps) => {
<span className="truncate">{selectedFocus?.name}</span>
</div>
) : (
<div className="text-sm font-medium text-custom-text-300 my-auto flex items-center gap-2">
<AtSign className="size-4" /> <span>Add context </span>
<div className="text-sm font-medium my-auto flex items-center gap-2">
<AtSign className="size-4 text-custom-text-300" /> <span>Add context </span>
</div>
)}
</div>
@@ -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"
>
@@ -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: <AiSearchIcon className="size-4" />,
},
{
label: "Build",
value: "build",
icon: <AiWriteIcon className="size-4" />,
},
];
export const AiMode = (props: { aiMode: string; setAiMode: (mode: string) => void }) => {
const { aiMode, setAiMode } = props;
const selectedMode = MODE_OPTIONS.find((option) => option.value === aiMode);
return (
<CustomSelect
value={aiMode}
label={
<Tooltip tooltipContent="Select the mode of the AI to use for the conversation." position="top">
<div className="flex font-medium gap-2 w-full overflow-hidden">
<span className="flex items-center gap-2 text-custom-text-300">{selectedMode?.icon}</span>
<span className="text-sm truncate">{selectedMode?.label}</span>
</div>
</Tooltip>
}
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"
>
<div className="flex flex-col divide-y divide-custom-border-100 space-y-2 max-h-full">
<div>
{MODE_OPTIONS.map((option) => (
<CustomSelect.Option
key={option.value}
value={option.value}
className="text-sm text-custom-text-200 font-medium flex w-full"
>
<div className="flex items-center gap-2">
<span className="flex items-center gap-2 text-custom-text-300">{option.icon}</span>
<span className="text-sm truncate">{option.label}</span>
</div>
</CustomSelect.Option>
))}
</div>
</div>
</CustomSelect>
);
};
+33 -19
View File
@@ -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<TFocus>(
@@ -87,6 +90,7 @@ export const InputBox = observer((props: TProps) => {
);
const [loader, setLoader] = useState<TPiLoaders>("");
const [attachments, setAttachments] = useState<TPiAttachment[]>([]);
const [aiMode, setAiMode] = useState<string>(chatMode);
const [isEditorReady, setIsEditorReady] = useState(false);
//ref
const editorCommands = useRef<TEditCommands | null>(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) => {
/>
</div>
)}
{/* editor view */}
<PiChatEditorWithRef
setEditorCommand={(command) => {
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}
/>
<div className="flex w-full gap-3 justify-between">
{/* Focus */}
{!SPEECH_LOADERS.includes(loader) && (
{/* Focus */}
{!SPEECH_LOADERS.includes(loader) && (
<div className="mb-2 w-fit">
<FocusFilter
workspaceId={workspaceId}
projectId={projectIdToUse}
@@ -246,7 +242,24 @@ export const InputBox = observer((props: TProps) => {
setFocus={setFocus}
isLoading={isChatLoading && !!activeChatId}
/>
)}
</div>
)}
{/* editor view */}
<PiChatEditorWithRef
setEditorCommand={(command) => {
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}
/>
<div className="flex w-full gap-3 justify-between">
{/* Focus */}
{!SPEECH_LOADERS.includes(loader) && <AiMode aiMode={aiMode} setAiMode={setAiMode} />}
<div className="flex items-center w-full justify-end gap-2">
<div className="flex w-full justify-end">
{/* speech recorder */}
@@ -265,6 +278,7 @@ export const InputBox = observer((props: TProps) => {
setLoader={setLoader}
isFullScreen={isFullScreen}
focus={focus}
mode={aiMode}
/>
</WithFeatureFlagHOC>
{!SPEECH_LOADERS.includes(loader) && (
@@ -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) => {
</span>
)}
</div>
<span className="text-left text-sm break-words line-clamp-2">{prompt.text}</span>
</button>
);
+36 -7
View File
@@ -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<void>;
getInstance: (workspaceId: string) => Promise<TInstanceResponse>;
fetchUserThreads: (workspaceId: string | undefined, isProjectChat: boolean) => void;
@@ -78,7 +80,12 @@ export interface IPiChatStore {
fetchModels: (workspaceId?: string) => Promise<void>;
abortStream: (chatId: string) => void;
setActiveModel: (model: TAiModels) => void;
createNewChat: (focus: TFocus, isProjectChat: boolean, workspaceId: string | undefined) => Promise<string>;
createNewChat: (
focus: TFocus,
mode: string,
isProjectChat: boolean,
workspaceId: string | undefined
) => Promise<string>;
renameChat: (chatId: string, title: string, workspaceId: string | undefined) => Promise<void>;
deleteChat: (chatId: string, workspaceSlug: string) => Promise<void>;
favoriteChat: (chatId: string, workspaceId: string | undefined) => Promise<void>;
@@ -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,
+3
View File
@@ -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 = {
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import type { ISvgIcons } from "./type";
export const AiSearchIcon: React.FC<ISvgIcons> = ({ className = "", ...rest }) => (
<svg
viewBox="0 0 16 16"
className={`${className}`}
stroke="transparent"
xmlns="http://www.w3.org/2000/svg"
fill="none"
{...rest}
>
<path
d="M14 14L11.1 11.1M7.33333 4C9.17428 4 10.6667 5.49238 10.6667 7.33333M12.6667 7.33333C12.6667 10.2789 10.2789 12.6667 7.33333 12.6667C4.38781 12.6667 2 10.2789 2 7.33333C2 4.38781 4.38781 2 7.33333 2C10.2789 2 12.6667 4.38781 12.6667 7.33333Z"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import type { ISvgIcons } from "./type";
export const AiWriteIcon: React.FC<ISvgIcons> = ({ className = "", ...rest }) => (
<svg
viewBox="0 0 16 16"
className={`${className}`}
stroke="transparent"
xmlns="http://www.w3.org/2000/svg"
fill="none"
{...rest}
>
<path
d="M10.8912 1.55807C11.8717 0.577612 13.4615 0.577611 14.442 1.55807C15.4224 2.53853 15.4224 4.12841 14.442 5.10885L5.63729 13.9126C5.45891 14.0909 5.31066 14.2442 5.13241 14.3618C4.98526 14.4588 4.82547 14.535 4.6578 14.5893C4.45463 14.6551 4.24256 14.6757 3.99179 14.7036L1.73593 14.9546C1.54722 14.9755 1.35846 14.9091 1.22421 14.7749C1.09024 14.6406 1.02455 14.4526 1.0455 14.2641L1.29647 12.0083C1.32434 11.7575 1.34496 11.5454 1.41073 11.3422C1.46503 11.1746 1.54127 11.0148 1.63827 10.8676C1.75583 10.6894 1.90907 10.5402 2.08749 10.3618L10.8912 1.55807ZM13.5582 2.44186C13.0659 1.94956 12.2673 1.94956 11.775 2.44186L2.97128 11.2465C2.75795 11.4599 2.71373 11.5074 2.68221 11.5551C2.6471 11.6084 2.61886 11.6663 2.59921 11.727C2.58157 11.7815 2.57204 11.8456 2.53866 12.146L2.37362 13.6255L3.85409 13.4614C4.15439 13.428 4.21856 13.4185 4.27304 13.4008C4.33368 13.3812 4.39169 13.3529 4.44491 13.3178C4.49261 13.2863 4.54021 13.2421 4.7535 13.0288L13.5582 4.22506C14.0505 3.73278 14.0504 2.93416 13.5582 2.44186Z"
fill="currentColor"
/>
<path
d="M2.55194 1.16482C2.69105 0.803119 2.76061 0.62227 2.86102 0.569808C2.94805 0.524338 3.05183 0.524338 3.13886 0.569808C3.23927 0.62227 3.30883 0.803119 3.44795 1.16482L3.578 1.50296C3.672 1.74737 3.71901 1.86957 3.7921 1.97236C3.85688 2.06347 3.93648 2.14307 4.02758 2.20785C4.13037 2.28094 4.25258 2.32794 4.49699 2.42194L4.83513 2.552C5.19682 2.69111 5.37767 2.76067 5.43014 2.86108C5.47561 2.94811 5.47561 3.05189 5.43014 3.13892C5.37767 3.23934 5.19683 3.30889 4.83513 3.44801L4.49699 3.57806C4.25258 3.67207 4.13037 3.71907 4.02758 3.79216C3.93648 3.85694 3.85688 3.93654 3.7921 4.02764C3.71901 4.13043 3.672 4.25264 3.578 4.49705L3.44795 4.83519C3.30883 5.19689 3.23928 5.37773 3.13886 5.4302C3.05183 5.47567 2.94805 5.47567 2.86102 5.4302C2.76061 5.37773 2.69105 5.19689 2.55194 4.83519L2.42188 4.49705C2.32788 4.25264 2.28088 4.13043 2.20779 4.02764C2.14301 3.93654 2.06341 3.85694 1.9723 3.79216C1.86951 3.71907 1.74731 3.67207 1.5029 3.57806L1.16476 3.44801C0.803058 3.30889 0.622209 3.23934 0.569747 3.13892C0.524277 3.05189 0.524277 2.94811 0.569747 2.86108C0.622209 2.76067 0.803058 2.69111 1.16476 2.552L1.50289 2.42194C1.74731 2.32794 1.86951 2.28094 1.9723 2.20785C2.06341 2.14307 2.143 2.06347 2.20778 1.97236C2.28088 1.86957 2.32788 1.74737 2.42188 1.50296L2.55194 1.16482Z"
fill="currentColor"
/>
</svg>
);
+2
View File
@@ -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";