Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@ jobs:
source .venv/bin/activate
uv pip install -e .

- name: Run tests
- name: Run unit tests
run: uv run pytest

- name: Run integration tests
run: uv run pytest tests/integration/
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ build-backend = "hatchling.build"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "--asyncio-mode=auto"
addopts = "--asyncio-mode=auto --ignore=tests/integration/"

[tool.black]
line-length = 88
Expand Down
18 changes: 15 additions & 3 deletions src/praga_core/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,23 @@ def _convert_page_type_to_uri_type(self, param_type: Any) -> Any:
origin = get_origin(param_type)
args = get_args(param_type)

if origin in (list, List) and args and self._is_page_type(args[0]):
# Handle Sequence[Page], List[Page], etc.
if origin in (list, List, Sequence) and args and self._is_page_type(args[0]):
return List[PageURI]

if self._is_optional_page_type(param_type):
return Union[PageURI, None]
# Handle Optional types
if origin is Union and len(args) == 2 and type(None) in args:
non_none_type = args[0] if args[1] is type(None) else args[1]
# Optional[Page] -> Optional[PageURI]
if self._is_page_type(non_none_type):
return Union[PageURI, None]
# Optional[Sequence[Page]] -> Optional[List[PageURI]]
elif (
get_origin(non_none_type) in (list, List, Sequence)
and get_args(non_none_type)
and self._is_page_type(get_args(non_none_type)[0])
):
return Union[List[PageURI], None]

# For non-Page types, return unchanged
return param_type
Expand Down
267 changes: 200 additions & 67 deletions src/praga_core/integrations/mcp/descriptions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Description templates for MCP tools and resources."""

import inspect
from typing import Any, List, Union, get_args, get_origin, get_type_hints
from typing import Any, Dict, List, Union, get_args, get_origin, get_type_hints

from praga_core.action_executor import ActionFunction
from praga_core.types import Page
from praga_core.types import PageURI


def get_search_tool_description(type_names: List[str]) -> str:
Expand All @@ -15,136 +15,269 @@ def get_search_tool_description(type_names: List[str]) -> str:

Available page types: {', '.join(type_names)}

Parameters:
- instruction: Natural language search instruction (required)

Examples:
- "Find emails from Alice about project X"
- "Get calendar events for next week"
- "Show me all documents mentioning quarterly report"
- "Search for person named John Smith"

Response format:
- results: Array of search results with page references and resolved page content
"""


def get_pages_tool_description(type_names: List[str]) -> str:
"""Generate description for the get_pages tool. Optionally accepts allow_stale to return invalid pages."""
return f"""Get specific pages/documents by their type and ID(s).
return f"""Get specific pages/documents by their URIs.

Returns JSON with complete page content and metadata. Supports both single page and bulk operations.

Available page types: {', '.join(type_names)}

Single page examples:
- Get email: page_type="EmailPage", page_ids=["msg_12345"]
- Get calendar event: page_type="CalendarEventPage", page_ids=["event_67890"]
- Use aliases: page_type="Email", page_ids=["msg_12345"]
Parameters:
- page_uris: List of page URIs in format "PageType:id"
- allow_stale: Optional boolean to allow stale data (default: false)

Bulk examples:
- Get multiple emails: page_type="EmailPage", page_ids=["msg_1", "msg_2", "msg_3"]
- Get multiple events: page_type="CalendarEvent", page_ids=["event_1", "event_2"]
Examples:
- Single page: page_uris=["EmailPage:msg_12345"]
- Multiple pages: page_uris=["EmailPage:msg_1", "CalendarEventPage:event_2"]
- Full URI format: page_uris=["root/EmailPage:msg@1", "root/CalendarEventPage:event@2"]

Response format:
- requested_count: Number of pages requested
- successful_count: Number of pages successfully retrieved
- error_count: Number of pages that failed
- pages: Array of successfully retrieved pages with uri, content, and status
- errors: Array of failed pages with uri, status, and error message (if any failures)
"""


def get_action_tool_description(action_name: str, action_func: ActionFunction) -> str:
"""Generate description for an action tool."""
# Get function signature and docstring
sig = inspect.signature(action_func)
doc = inspect.getdoc(action_func) or "Perform an action on a page."
def get_invoke_action_tool_description(actions: Dict[str, ActionFunction]) -> str:
"""Generate comprehensive description for the single invoke_action tool."""
if not actions:
return """Execute any registered action on pages.

This tool provides a unified interface for invoking actions on pages. Actions are operations that can modify page state or perform operations on pages.

Available actions: No actions available

Parameters:
- action_name: str - Name of the action to execute (required)
- action_input: Dict[str, Any] - Dictionary containing action parameters (required)

Returns JSON with success status and error information:
- Success: {"success": true}
- Failure: {"success": false, "error": "<error_message>"}
"""

action_names = list(actions.keys())
actions_text = ", ".join(action_names)

# Generate detailed descriptions for each action
action_details = []
for action_name, action_func in actions.items():
action_detail = _generate_action_detail(action_name, action_func)
action_details.append(action_detail)

action_details_text = "\n\n".join(action_details)

return f"""Execute any registered action on pages.

# Get type hints for proper parameter descriptions
This tool provides a unified interface for invoking actions on pages. Actions are operations that can modify page state or perform operations on pages.

Available actions: {actions_text}

Parameters:
- action_name: str - Name of the action to execute (required)
- action_input: Dict[str, Any] - Dictionary containing action parameters (required)

Action input format:
- Page parameters should be provided as string URIs
- Simple format: {{"email": "EmailPage:msg_12345"}}
- Full format: {{"email": "root/EmailPage:msg_12345@1"}}
- For actions with multiple parameters: {{"email": "EmailPage:msg_1", "mark_read": true}}

Returns JSON with success status and error information:
- Success: {{"success": true}}
- Failure: {{"success": false, "error": "<error_message>"}}

## Available Actions

{action_details_text}

## Example Usage

{_generate_example_usage(actions)}

Note: This tool can execute any registered action. The specific parameters required depend on the action being invoked.
"""


def _generate_action_detail(action_name: str, action_func: ActionFunction) -> str:
"""Generate detailed description for a single action."""
# Get docstring
doc = inspect.getdoc(action_func) or "No description available."

# Get function signature and type hints
sig = inspect.signature(action_func)
try:
type_hints = get_type_hints(action_func)
except Exception:
type_hints = {}

# Generate parameter descriptions
param_descriptions = []
for param_name, param in sig.parameters.items():
# Get the type annotation - transform Page types to PageURI
param_type = type_hints.get(param_name, param.annotation)
if param_type == inspect.Parameter.empty:
param_type = "Any"

# Transform Page types to PageURI types for description
# Transform Page types to URI types for description
transformed_type = _convert_page_type_to_uri_type_for_description(param_type)

# Check if parameter has default value
has_default = param.default != inspect.Parameter.empty
default_text = (
f" (default: {param.default})"
if param.default != inspect.Parameter.empty
else ""
f" (optional, default: {param.default})" if has_default else " (required)"
)
param_descriptions.append(f"- {param_name}: {transformed_type}{default_text}")

param_descriptions.append(f" - {param_name}: {transformed_type}{default_text}")

param_text = (
"\n".join(param_descriptions)
if param_descriptions
else "No parameters required."
"\n".join(param_descriptions) if param_descriptions else " No parameters"
)

return f"""Action: {action_name}
return f"""### {action_name}

{doc}

Parameters:
{param_text}

Returns JSON with success status (true/false) and any error message if the action fails.

Example usage:
- Provide the page URI and any additional parameters required by the action
- The action will be executed on the specified page
- Returns {{"success": true}} on success or {{"success": false, "error": "..."}} on failure
"""
{param_text}"""


def _convert_page_type_to_uri_type_for_description(param_type: Any) -> str:
"""Convert Page-related type annotations to PageURI equivalents for descriptions."""
# Handle direct Page type
if _is_page_type(param_type):
return "PageURI"
"""Convert Page-related type annotations to string equivalents for descriptions."""
# Handle direct PageURI type
if param_type is PageURI:
return "str (page URI)"

# Handle generic types like List[Page], Optional[Page], etc.
# Handle generic types like List[PageURI], Optional[PageURI], etc.
origin = get_origin(param_type)
args = get_args(param_type)

if origin in (list, List):
if args and _is_page_type(args[0]):
return "List[PageURI]"
elif args:
# Handle List[SomePageType]
inner_type = _convert_page_type_to_uri_type_for_description(args[0])
return f"List[{inner_type}]"
else:
return "List"

if _is_optional_page_type(param_type):
return "Optional[PageURI]"
if origin in (list, List) and args and args[0] is PageURI:
return "List[str] (page URIs)"

# Handle Optional[List[Page]] and similar complex types
if origin is Union:
non_none_types = [arg for arg in args if arg is not type(None)]
if len(non_none_types) == 1:
inner_desc = _convert_page_type_to_uri_type_for_description(
non_none_types[0]
)
return f"Optional[{inner_desc}]"

# For non-Page types, return string representation
if origin is Union and len(args) == 2 and type(None) in args:
non_none_type = args[0] if args[1] is type(None) else args[1]
if non_none_type is PageURI:
return "Optional[str] (page URI)"
# Handle Optional[List[PageURI]]
elif (
get_origin(non_none_type) is list
and get_args(non_none_type)
and get_args(non_none_type)[0] is PageURI
):
return "Optional[List[str]] (page URIs)"

# For non-PageURI types, return string representation
if hasattr(param_type, "__name__"):
return str(param_type.__name__)
else:
return str(param_type)


def _generate_example_usage(actions: Dict[str, ActionFunction]) -> str:
"""Generate example usage for actions."""
if not actions:
return "No actions available for examples."

examples = []
for action_name, action_func in actions.items():
example = _generate_action_example(action_name, action_func)
if example:
examples.append(example)

return "\n".join(examples[:3]) # Show up to 3 examples to keep it manageable


def _generate_action_example(action_name: str, action_func: ActionFunction) -> str:
"""Generate a usage example for a specific action."""
sig = inspect.signature(action_func)
try:
type_hints = get_type_hints(action_func)
except Exception:
type_hints = {}

# Build example action_input
example_params: Dict[str, Any] = {}
for param_name, param in sig.parameters.items():
param_type = type_hints.get(param_name, param.annotation)

# Generate example values based on parameter type
if param_type is PageURI or _is_page_type(param_type):
# Use the parameter name to infer page type
if "email" in param_name.lower():
example_params[param_name] = "EmailPage:msg_12345"
elif "person" in param_name.lower():
example_params[param_name] = "PersonPage:person_123"
elif "thread" in param_name.lower():
example_params[param_name] = "EmailThreadPage:thread_456"
else:
# Generic page URI
example_params[param_name] = "SomePage:page_123"
elif _is_list_page_type(param_type):
# List of pages
if "email" in param_name.lower():
example_params[param_name] = ["EmailPage:msg_1", "EmailPage:msg_2"]
elif "person" in param_name.lower() or "recipient" in param_name.lower():
example_params[param_name] = [
"PersonPage:person_1",
"PersonPage:person_2",
]
else:
example_params[param_name] = ["SomePage:page_1", "SomePage:page_2"]
elif param_type == str or param_type is str:
# String parameters
if "message" in param_name.lower():
example_params[param_name] = "Your message here"
elif "subject" in param_name.lower():
example_params[param_name] = "Email subject"
else:
example_params[param_name] = f"example_{param_name}"
elif param_type == bool or param_type is bool:
example_params[param_name] = True
elif param.default != inspect.Parameter.empty:
# Skip optional parameters with defaults
continue
else:
# For other types, use a generic example
example_params[param_name] = f"<{param_name}_value>"

if not example_params:
return f'- action_name="{action_name}", action_input={{}}'

return f'- action_name="{action_name}", action_input={example_params}'


def _is_page_type(param_type: Any) -> bool:
"""Check if a type is Page or a subclass of Page."""
from praga_core.types import Page

return param_type is Page or (
isinstance(param_type, type) and issubclass(param_type, Page)
)


def _is_optional_page_type(param_type: Any) -> bool:
"""Check if a type is Optional[Page] or similar union with None."""
def _is_list_page_type(param_type: Any) -> bool:
"""Check if a type is List[Page] or similar."""
origin = get_origin(param_type)
args = get_args(param_type)

if origin is Union and len(args) == 2 and type(None) in args:
non_none_type = args[0] if args[1] is type(None) else args[1]
return _is_page_type(non_none_type)
if origin in (list, List) and args:
return _is_page_type(args[0])

return False
Loading