Support Tool Output in ValueSchema of ToolDefinition (#487)

## Before

### Tool: ``GoogleNews.SearchNewsStories``

```python
@tool(requires_secrets=["SERP_API_KEY"])
async def search_news_stories(
    context: ToolContext,
    keywords: Annotated[
        str,
        "Keywords to search for news articles. E.g. 'Apple launches new iPhone'.",
    ],
    country_code: Annotated[
        CountryCode | None,
        "2-character country code to search for news articles. "
        "E.g. 'us' (United States). "
        f"Defaults to '{DEFAULT_GOOGLE_NEWS_COUNTRY}'.",
    ] = None,
    language_code: Annotated[
        LanguageCode,
        "2-character language code to search for news articles. E.g. 'en' (English). "
        f"Defaults to '{DEFAULT_GOOGLE_NEWS_LANGUAGE}'.",
    ] = DEFAULT_GOOGLE_NEWS_LANGUAGE,
    limit: Annotated[
        int | None,
        "Maximum number of news articles to return. Defaults to None "
        "(returns all results found by the API).",
    ] = None,
) -> Annotated[dict[str, Any]]:
    """Search for news articles related to a given query."""
    ...
```


### Tool Definition: ``GoogleNews.SearchNewsStories``
```
  {
    "name": "SearchNewsStories",
    "fully_qualified_name": "GoogleNews.SearchNewsStories",
    "description": "Search for news articles related to a given query.",
    "toolkit": {
      "name": "GoogleNews",
      "description": "Arcade.dev LLM tools for getting new via Google News",
      "version": "2.0.0"
    },
    "input": {
      "parameters": [
        {
          "name": "keywords",
          "required": true,
          "description": "Keywords to search for news articles. E.g. 'Apple launches new iPhone'.",
          "value_schema": {
            "val_type": "string",
            "inner_val_type": null,
            "enum": null,
          },
          "inferrable": true
        },
        {
          "name": "country_code",
          "required": false,
          "description": "2-character country code to search for news articles. E.g. 'us' (United States). Defaults to 'None'.",
          "value_schema": {
            "val_type": "string",
            "inner_val_type": null,
            "enum": null,
          },
          "inferrable": true
        },
        {
          "name": "language_code",
          "required": false,
          "description": "2-character language code to search for news articles. E.g. 'en' (English). Defaults to 'en'.",
          "value_schema": {
            "val_type": "string",
            "inner_val_type": null,
            "enum": null,
          },
          "inferrable": true
        },
        {
          "name": "limit",
          "required": false,
          "description": "Maximum number of news articles to return. Defaults to None (returns all results found by the API).",
          "value_schema": {
            "val_type": "integer",
            "inner_val_type": null,
            "enum": null,

          },
          "inferrable": true
        }
      ]
    },
    "output": {
      "description": "News search results with article details.",
      "available_modes": [
        "value",
        "error"
      ],
      "value_schema": {
        "val_type": "json"
      }
    },
    "requirements": {
      "authorization": null,
      "secrets": [
        {
          "key": "serp_api_key"
        }
      ],
      "metadata": null
    },
    "deprecation_message": null
  },
```

## After

### Enhanced Tool: ``GoogleNews.SearchNewsStories``

```python

"""Type definitions for Google News API responses and parameters."""

from typing_extensions import TypedDict

CountryCode = str
LanguageCode = str


class SearchNewsParams(TypedDict):
    """Input parameters for searching news articles."""

    keywords: str
    """Search query terms to find relevant news articles \
    (e.g., 'Apple launches new iPhone')."""

    country_code: CountryCode | None
    """Optional 2-letter country code to filter news by region \
    (e.g., 'us' for United States, 'uk' for United Kingdom)."""

    language_code: LanguageCode | None
    """Optional 2-letter language code to filter news by language \
    (e.g., 'en' for English, 'es' for Spanish)."""

    limit: int | None
    """Optional maximum number of news articles to return. \
    If not specified, returns all results from the API."""


class SourceInfo(TypedDict, total=False):
    """Information about the news source/publication."""

    name: str
    """Name of the publication (e.g., 'CNN', 'BBC News', 'The New York Times')."""

    icon: str
    """URL to the source's favicon or logo image."""

    authors: list[str]
    """List of author names for the article, if available."""


class NewsResult(TypedDict, total=False):
    """Individual news article from the Google News API response."""

    position: int
    """Ranking position of this result in the search results."""

    title: str
    """Headline or title of the news article."""

    link: str
    """Full URL to the original news article."""

    source: SourceInfo
    """Information about the publication source."""

    date: str
    """Publication date and time (e.g., '2 hours ago', 'Dec 15, 2023')."""

    snippet: str
    """Brief excerpt or summary from the article content."""

    thumbnail: str
    """URL to a high-resolution thumbnail image for the article."""

    thumbnail_small: str
    """URL to a low-resolution thumbnail image for the article."""

    story_token: str
    """Token for accessing full coverage of this news story across multiple sources."""

    stories: list["NewsResult"]
    """Related news stories from other sources covering the same topic."""

    highlight: dict
    """Additional highlighted information about the story."""


class SearchMetadata(TypedDict, total=False):
    """Metadata about the search request and processing."""

    id: str
    """Unique identifier for this search request within SerpApi."""

    status: str
    """Current processing status ('Processing', 'Success', or 'Error')."""

    json_endpoint: str
    """URL to retrieve the JSON results for this search."""

    created_at: str
    """Timestamp when the search request was created."""

    processed_at: str
    """Timestamp when the search request was processed."""

    google_news_url: str
    """Original Google News URL that would return these results."""

    total_time_taken: float
    """Total time in seconds taken to process this search."""


class SearchParameters(TypedDict, total=False):
    """Parameters used for the search request."""

    engine: str
    """Search engine used (always 'google_news' for this API)."""

    q: str
    """Search query string."""

    gl: str
    """Country code used for geographic filtering."""

    hl: str
    """Language code used for language filtering."""

    topic_token: str
    """Token for accessing specific news topics (e.g., 'World', 'Business', 'Technology')."""

    publication_token: str
    """Token for accessing news from specific publishers."""


class MenuLink(TypedDict):
    """Navigation link for news categories or topics."""

    title: str
    """Display text for the menu item (e.g., 'Technology', 'Sports', 'Business')."""

    topic_token: str
    """Token to access this specific topic or category."""

    serpapi_link: str
    """SerpApi URL to search within this topic."""


class TopStoriesLink(TypedDict):
    """Link to top stories section."""

    topic_token: str
    """Token to access top stories."""

    serpapi_link: str
    """SerpApi URL to retrieve top stories."""


class GoogleNewsResponse(TypedDict, total=False):
    """Complete response from the Google News API."""

    search_metadata: SearchMetadata
    """Metadata about the search request and processing."""

    search_parameters: SearchParameters
    """Parameters that were used for this search."""

    news_results: list[NewsResult]
    """List of news articles matching the search criteria."""

    menu_links: list[MenuLink]
    """Navigation links to different news categories and topics."""

    top_stories_link: TopStoriesLink
    """Link to access top stories."""

    title: str
    """Title of the page or topic being displayed."""


class SimplifiedNewsResult(TypedDict):
    """Simplified news article format for tool output."""

    title: str
    """Headline of the news article."""

    link: str
    """URL to the full article."""

    source: str | None
    """Name of the publication source."""

    date: str | None
    """When the article was published."""

    snippet: str | None
    """Brief excerpt from the article."""


class SearchNewsOutput(TypedDict):
    """Output format for the search_news_stories tool."""

    news_results: list[SimplifiedNewsResult]
    """List of news articles in simplified format."""

@tool(requires_secrets=["SERP_API_KEY"])
async def search_news_stories(
    context: ToolContext,
    keywords: Annotated[
        str,
        "Keywords to search for news articles. E.g. 'Apple launches new iPhone'.",
    ],
    country_code: Annotated[
        CountryCode | None,
        "2-character country code to search for news articles. "
        "E.g. 'us' (United States). "
        f"Defaults to '{DEFAULT_GOOGLE_NEWS_COUNTRY}'.",
    ] = None,
    language_code: Annotated[
        LanguageCode,
        "2-character language code to search for news articles. E.g. 'en' (English). "
        f"Defaults to '{DEFAULT_GOOGLE_NEWS_LANGUAGE}'.",
    ] = DEFAULT_GOOGLE_NEWS_LANGUAGE,
    limit: Annotated[
        int | None,
        "Maximum number of news articles to return. Defaults to None "
        "(returns all results found by the API).",
    ] = None,
) -> Annotated[SearchNewsOutput, "News search results with article details."]:
    """Search for news articles related to a given query."""
    ...

```

### Enhanced Tool Definition: ``GoogleNews.SearchNewsStories`` 

```json

  {
    "name": "SearchNewsStories",
    "fully_qualified_name": "GoogleNews.SearchNewsStories",
    "description": "Search for news articles related to a given query.",
    "toolkit": {
      "name": "GoogleNews",
      "description": "Arcade.dev LLM tools for getting new via Google News",
      "version": "2.0.0"
    },
    "input": {
      "parameters": [
        {
          "name": "keywords",
          "required": true,
          "description": "Keywords to search for news articles. E.g. 'Apple launches new iPhone'.",
          "value_schema": {
            "val_type": "string",
            "inner_val_type": null,
            "enum": null,
            "properties": null,
            "inner_properties": null,
            "description": null
          },
          "inferrable": true
        },
        {
          "name": "country_code",
          "required": false,
          "description": "2-character country code to search for news articles. E.g. 'us' (United States). Defaults to 'None'.",
          "value_schema": {
            "val_type": "string",
            "inner_val_type": null,
            "enum": null,
            "properties": null,
            "inner_properties": null,
            "description": null
          },
          "inferrable": true
        },
        {
          "name": "language_code",
          "required": false,
          "description": "2-character language code to search for news articles. E.g. 'en' (English). Defaults to 'en'.",
          "value_schema": {
            "val_type": "string",
            "inner_val_type": null,
            "enum": null,
            "properties": null,
            "inner_properties": null,
            "description": null
          },
          "inferrable": true
        },
        {
          "name": "limit",
          "required": false,
          "description": "Maximum number of news articles to return. Defaults to None (returns all results found by the API).",
          "value_schema": {
            "val_type": "integer",
            "inner_val_type": null,
            "enum": null,
            "properties": null,
            "inner_properties": null,
            "description": null
          },
          "inferrable": true
        }
      ]
    },
    "output": {
      "description": "News search results with article details.",
      "available_modes": [
        "value",
        "error"
      ],
      "value_schema": {
        "val_type": "json",
        "inner_val_type": null,
        "enum": null,
        "properties": {
          "news_results": {
            "val_type": "array",
            "inner_val_type": "json",
            "enum": null,
            "properties": null,
            "inner_properties": {
              "title": {
                "val_type": "string",
                "inner_val_type": null,
                "enum": null,
                "properties": null,
                "inner_properties": null,
                "description": "Headline of the news article."
              },
              "link": {
                "val_type": "string",
                "inner_val_type": null,
                "enum": null,
                "properties": null,
                "inner_properties": null,
                "description": "URL to the full article."
              },
              "source": {
                "val_type": "string",
                "inner_val_type": null,
                "enum": null,
                "properties": null,
                "inner_properties": null,
                "description": "Name of the publication source."
              },
              "date": {
                "val_type": "string",
                "inner_val_type": null,
                "enum": null,
                "properties": null,
                "inner_properties": null,
                "description": "When the article was published."
              },
              "snippet": {
                "val_type": "string",
                "inner_val_type": null,
                "enum": null,
                "properties": null,
                "inner_properties": null,
                "description": "Brief excerpt from the article."
              }
            },
            "description": "List of news articles in simplified format."
          }
        },
        "inner_properties": null,
        "description": null
      }
    },
    "requirements": {
      "authorization": null,
      "secrets": [
        {
          "key": "serp_api_key"
        }
      ],
      "metadata": null
    },
    "deprecation_message": null
  },

```

---------

Co-authored-by: Eric Gustin <eric@arcade.dev>
This commit is contained in:
Sam Partee 2025-07-24 15:32:35 -07:00 committed by GitHub
parent 4144a42392
commit 27a6cd31a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1429 additions and 172 deletions

View file

@ -36,9 +36,13 @@ def display_tools_table(tools: list[ToolDefinition]) -> None:
console.print(table) console.print(table)
def display_tool_details(tool: ToolDefinition) -> None: def display_tool_details(tool: ToolDefinition, worker: bool = False) -> None: # noqa: C901
""" """
Display detailed information about a specific tool using multiple panels. Display detailed information about a specific tool using multiple panels.
Args:
tool: The tool definition to display
worker: If True, show full worker response structure. If False, show only value structure.
""" """
# Description Panel # Description Panel
description_panel = Panel( description_panel = Panel(
@ -80,31 +84,147 @@ def display_tool_details(tool: ToolDefinition) -> None:
border_style="green", border_style="green",
) )
# Output Panel # Output Panel - Show different levels based on worker flag
output = tool.output output = tool.output
if output: if output and output.value_schema:
output_description = output.description or "No description available." output_table = Table(show_header=True, header_style="bold blue")
output_types = ", ".join(output.available_modes) output_table.add_column("Field", style="cyan")
output_val_type = output.value_schema.val_type if output.value_schema else "N/A" output_table.add_column("Type", style="magenta")
output_details = Text.assemble( output_table.add_column("Description", style="white")
("Description: ", "bold"),
(output_description, ""), if worker:
"\n", # Show full worker response structure
("Available Modes: ", "bold"), output_table.add_row(
(output_types, ""), "[bold]Response Structure[/bold]",
"\n", "",
("Value Type: ", "bold"), "[dim]The tool response follows this structure:[/dim]",
(output_val_type, ""), )
)
# Available modes determine which fields can be present
modes = output.available_modes
if "value" in modes:
# Show the value field with its schema
value_type: str = output.value_schema.val_type
display_type: str = value_type # Separate variable for display string
if value_type == "array" and output.value_schema.inner_val_type:
display_type = rf"array\[{output.value_schema.inner_val_type}]"
elif output.value_schema.enum:
display_type = f"{value_type} (enum: {', '.join(output.value_schema.enum)})"
output_table.add_row(
" value",
display_type,
output.description or "The successful result from the tool",
)
# If the value is a json type with properties, show them
if (
output.value_schema.val_type == "json"
and hasattr(output.value_schema, "properties")
and output.value_schema.properties
):
_add_nested_properties(output_table, output.value_schema.properties, indent=2)
if "error" in modes:
output_table.add_row(
" error", "object", "[dim]Error details if the tool fails[/dim]"
)
output_table.add_row(
" message", "string", "[dim]User-facing error message[/dim]"
)
output_table.add_row(
" developer_message",
"string?",
"[dim]Technical error details (optional)[/dim]",
)
if "null" in modes:
output_table.add_row(" value", "null", "[dim]Tool can return null/None[/dim]")
# Additional fields that may be present
output_table.add_row("", "", "")
output_table.add_row(
"[bold]Additional Fields[/bold]",
"",
"[dim]May be present in any response:[/dim]",
)
output_table.add_row(
" logs", "array?", "[dim]Optional warnings or info messages[/dim]"
)
output_table.add_row(
" requires_authorization",
"object?",
"[dim]OAuth flow details if auth needed[/dim]",
)
else:
# Show only the value structure (simplified view)
# Show the value type and description
display_type = _format_type_string(output.value_schema)
if output.value_schema.enum:
display_type = (
f"{output.value_schema.val_type} (enum: {', '.join(output.value_schema.enum)})"
)
output_table.add_row(
"[bold]Value[/bold]",
display_type,
output.description or "The return value from the tool",
)
# If the value is a json type with properties, show them
if (
output.value_schema.val_type == "json"
and hasattr(output.value_schema, "properties")
and output.value_schema.properties
):
_add_nested_properties(output_table, output.value_schema.properties, indent=1)
# Create subtitle with modes info
modes_text = Text()
modes_text.append("Response Modes: ", style="bold")
modes_text.append("One of { ", style="dim")
for i, mode in enumerate(output.available_modes):
if i > 0:
modes_text.append(", ", style="dim")
if mode == "value":
modes_text.append(mode, style="green")
elif mode == "error":
modes_text.append(mode, style="red")
elif mode == "null":
modes_text.append(mode, style="yellow")
else:
modes_text.append(mode, style="magenta")
modes_text.append(" }", style="dim")
output_panel = Panel( output_panel = Panel(
output_details, output_table,
title="Expected Output", title="Output Schema",
border_style="blue", border_style="blue",
subtitle=modes_text,
) )
else: else:
# No schema defined
no_schema_table = Table(show_header=False)
no_schema_table.add_column()
if worker:
no_schema_table.add_row(
"[dim]No output schema defined. The tool response will follow this structure:[/dim]"
)
no_schema_table.add_row("")
no_schema_table.add_row("[cyan]Response Structure:[/cyan]")
no_schema_table.add_row(" • [bold]value[/bold]: null (when successful)")
no_schema_table.add_row(" • [bold]error[/bold]: object (when failed)")
no_schema_table.add_row(" • [bold]logs[/bold]: array? (optional warnings/info)")
else:
no_schema_table.add_row("[dim]No output schema defined.[/dim]")
no_schema_table.add_row("")
no_schema_table.add_row("The tool returns: [bold]null[/bold]")
output_panel = Panel( output_panel = Panel(
"No output information available.", no_schema_table,
title="Expected Output", title="Output Schema",
border_style="blue", border_style="blue",
) )
@ -114,6 +234,80 @@ def display_tool_details(tool: ToolDefinition) -> None:
console.print(output_panel) console.print(output_panel)
def _add_nested_properties(
table: Table,
properties: dict[str, Any],
indent: int = 0,
is_array_item: bool = False,
) -> None:
"""
Recursively add nested properties to the output table.
Args:
table: The Rich table to add rows to
properties: Dictionary of property names to ValueSchema objects
indent: Current indentation level
is_array_item: Whether these properties are for array items
"""
indent_prefix = " " * indent
# Show array item indicator if needed
if is_array_item and indent > 0:
table.add_row(
f"{indent_prefix[:-2]}[item]",
"",
"[dim]Each item in array:[/dim]",
)
for prop_name, prop_schema in properties.items():
# Format the type string
type_str = _format_type_string(prop_schema)
# Add the property row with better descriptions
description = ""
# For nested properties, we don't have descriptions yet, but we could add them
if hasattr(prop_schema, "description") and prop_schema.description:
description = prop_schema.description
table.add_row(
f"{indent_prefix}{prop_name}",
type_str,
f"[dim]{description}[/dim]" if description else "",
)
# Recursively add nested properties if this is a json type with properties
if (
prop_schema.val_type == "json"
and hasattr(prop_schema, "properties")
and prop_schema.properties
):
_add_nested_properties(table, prop_schema.properties, indent + 1)
# Handle arrays with inner properties
elif (
prop_schema.val_type == "array"
and hasattr(prop_schema, "inner_properties")
and prop_schema.inner_properties
):
_add_nested_properties(
table, prop_schema.inner_properties, indent + 1, is_array_item=True
)
def _format_type_string(schema: Any) -> str:
"""Format type string for display."""
type_str: str = schema.val_type
if schema.val_type == "array":
if hasattr(schema, "inner_properties") and schema.inner_properties:
type_str = r"array\[object]"
elif schema.inner_val_type:
type_str = rf"array\[{schema.inner_val_type}]"
elif schema.enum:
type_str = f"{type_str} (enum)"
return type_str
def display_tool_messages(tool_messages: list[dict]) -> None: def display_tool_messages(tool_messages: list[dict]) -> None:
for message in tool_messages: for message in tool_messages:
if message["role"] == "assistant": if message["role"] == "assistant":
@ -124,7 +318,8 @@ def display_tool_messages(tool_messages: list[dict]) -> None:
) )
elif message["role"] == "tool": elif message["role"] == "tool":
console.print( console.print(
f"[bold]'{message['name']}' tool returned:[/bold] {message['content']}", style="dim" f"[bold]'{message['name']}' tool returned:[/bold] {message['content']}",
style="dim",
) )

View file

@ -55,7 +55,7 @@ cli = typer.Typer(
cls=OrderCommands, cls=OrderCommands,
add_completion=False, add_completion=False,
no_args_is_help=True, no_args_is_help=True,
pretty_exceptions_enable=False, pretty_exceptions_enable=True,
pretty_exceptions_show_locals=False, pretty_exceptions_show_locals=False,
pretty_exceptions_short=True, pretty_exceptions_short=True,
rich_markup_mode="markdown", rich_markup_mode="markdown",
@ -68,11 +68,16 @@ cli.add_typer(
help="Manage deployments of tool servers (logs, list, etc)", help="Manage deployments of tool servers (logs, list, etc)",
rich_help_panel="Deployment", rich_help_panel="Deployment",
) )
console = Console() console = Console()
def handle_cli_error( def handle_cli_error(
message: str, error: Exception | None = None, debug: bool = True, should_exit: bool = True message: str,
error: Optional[Exception] = None,
debug: bool = True,
should_exit: bool = True,
) -> None: ) -> None:
"""Handle CLI error reporting with optional debug traceback and exit.""" """Handle CLI error reporting with optional debug traceback and exit."""
if error and debug: if error and debug:
@ -225,12 +230,34 @@ def show(
"--no-tls", "--no-tls",
help="Whether to disable TLS for the connection to the Arcade Engine.", help="Whether to disable TLS for the connection to the Arcade Engine.",
), ),
worker: bool = typer.Option(
False,
"--worker",
"-w",
help="Show full worker response structure including error, logs, and authorization fields (only applies when used with -t/--tool).",
),
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
) -> None: ) -> None:
""" """
Show the available toolkits or detailed information about a specific tool. Show the available toolkits or detailed information about a specific tool.
""" """
show_logic(toolkit, tool, host, local, port, force_tls, force_no_tls, debug) if worker and not tool:
console.print(
"⚠️ The -w/--worker flag only affects output when used with -t/--tool flag",
style="bold yellow",
)
show_logic(
toolkit=toolkit,
tool=tool,
host=host,
local=local,
port=port,
force_tls=force_tls,
force_no_tls=force_no_tls,
worker=worker,
debug=debug,
)
@cli.command( @cli.command(
@ -250,7 +277,7 @@ def chat(
"--host", "--host",
help="The Arcade Engine address to send chat requests to.", help="The Arcade Engine address to send chat requests to.",
), ),
port: int = typer.Option( port: Optional[int] = typer.Option(
None, None,
"-p", "-p",
"--port", "--port",
@ -388,7 +415,7 @@ def evals(
"--cloud", "--cloud",
help="Whether to run evaluations against the Arcade Cloud Engine. Overrides the 'host' option.", help="Whether to run evaluations against the Arcade Cloud Engine. Overrides the 'host' option.",
), ),
port: int = typer.Option( port: Optional[int] = typer.Option(
None, None,
"-p", "-p",
"--port", "--port",
@ -509,10 +536,14 @@ def serve(
show_default=True, show_default=True,
), ),
port: int = typer.Option( port: int = typer.Option(
"8002", "-p", "--port", help="Port for the app, defaults to ", show_default=True "8002",
"-p",
"--port",
help="Port for the app, defaults to ",
show_default=True,
), ),
disable_auth: bool = typer.Option( disable_auth: bool = typer.Option(
False, True,
"--no-auth", "--no-auth",
help="Disable authentication for the worker. Not recommended for production.", help="Disable authentication for the worker. Not recommended for production.",
show_default=True, show_default=True,
@ -559,7 +590,9 @@ def serve(
@cli.command( @cli.command(
help="Start a server with locally installed Arcade tools", rich_help_panel="Launch", hidden=True help="Start a server with locally installed Arcade tools",
rich_help_panel="Launch",
hidden=True,
) )
def workerup( def workerup(
host: str = typer.Option( host: str = typer.Option(
@ -568,7 +601,11 @@ def workerup(
show_default=True, show_default=True,
), ),
port: int = typer.Option( port: int = typer.Option(
"8002", "-p", "--port", help="Port for the app, defaults to ", show_default=True "8002",
"-p",
"--port",
help="Port for the app, defaults to ",
show_default=True,
), ),
disable_auth: bool = typer.Option( disable_auth: bool = typer.Option(
False, False,
@ -610,7 +647,10 @@ def workerup(
@cli.command(help="Deploy toolkits to Arcade Cloud", rich_help_panel="Deployment") @cli.command(help="Deploy toolkits to Arcade Cloud", rich_help_panel="Deployment")
def deploy( def deploy(
deployment_file: str = typer.Option( deployment_file: str = typer.Option(
"worker.toml", "--deployment-file", "-d", help="The deployment file to deploy." "worker.toml",
"--deployment-file",
"-d",
help="The deployment file to deploy.",
), ),
cloud_host: str = typer.Option( cloud_host: str = typer.Option(
PROD_CLOUD_HOST, PROD_CLOUD_HOST,
@ -619,7 +659,7 @@ def deploy(
help="The Arcade Cloud host to deploy to.", help="The Arcade Cloud host to deploy to.",
hidden=True, hidden=True,
), ),
cloud_port: int = typer.Option( cloud_port: Optional[int] = typer.Option(
None, None,
"--cloud-port", "--cloud-port",
"-cp", "-cp",
@ -632,7 +672,7 @@ def deploy(
"-h", "-h",
help="The Arcade Engine host to register the worker to.", help="The Arcade Engine host to register the worker to.",
), ),
port: int = typer.Option( port: Optional[int] = typer.Option(
None, None,
"--port", "--port",
"-p", "-p",
@ -674,7 +714,10 @@ def deploy(
try: try:
# Attempt to deploy worker # Attempt to deploy worker
worker.request().execute(cloud_client, engine_client) worker.request().execute(cloud_client, engine_client)
console.log(f"✅ Worker '{worker.config.id}' deployed successfully.", style="dim") console.log(
f"✅ Worker '{worker.config.id}' deployed successfully.",
style="dim",
)
except Exception as e: except Exception as e:
handle_cli_error(f"Failed to deploy worker '{worker.config.id}'", e, debug) handle_cli_error(f"Failed to deploy worker '{worker.config.id}'", e, debug)

View file

@ -1,3 +1,5 @@
from typing import Optional
import typer import typer
from rich.console import Console from rich.console import Console
from rich.markup import escape from rich.markup import escape
@ -7,13 +9,14 @@ from arcade_cli.utils import create_cli_catalog, get_tools_from_engine
def show_logic( def show_logic(
toolkit: str | None, toolkit: Optional[str],
tool: str | None, tool: Optional[str],
host: str, host: str,
local: bool, local: bool,
port: int | None, port: Optional[int],
force_tls: bool, force_tls: bool,
force_no_tls: bool, force_no_tls: bool,
worker: bool,
debug: bool, debug: bool,
) -> None: ) -> None:
"""Wrapper function for the `arcade show` CLI command """Wrapper function for the `arcade show` CLI command
@ -42,7 +45,7 @@ def show_logic(
console.print(f"❌ Tool '{tool}' not found.", style="bold red") console.print(f"❌ Tool '{tool}' not found.", style="bold red")
typer.Exit(code=1) typer.Exit(code=1)
else: else:
display_tool_details(tool_def) display_tool_details(tool_def, worker=worker)
else: else:
# Display the list of tools as a table # Display the list of tools as a table
display_tools_table(tools) display_tools_table(tools)

View file

@ -18,12 +18,20 @@ from arcade_core import ToolCatalog, Toolkit
from arcade_core.config_model import Config from arcade_core.config_model import Config
from arcade_core.errors import ToolkitLoadError from arcade_core.errors import ToolkitLoadError
from arcade_core.schema import ToolDefinition from arcade_core.schema import ToolDefinition
from arcadepy import NOT_GIVEN, APIConnectionError, APIStatusError, APITimeoutError, Arcade from arcadepy import (
NOT_GIVEN,
APIConnectionError,
APIStatusError,
APITimeoutError,
Arcade,
)
from arcadepy.types import AuthorizationResponse from arcadepy.types import AuthorizationResponse
from openai import OpenAI, Stream from openai import OpenAI, Stream
from openai.types.chat.chat_completion import Choice as ChatCompletionChoice from openai.types.chat.chat_completion import Choice as ChatCompletionChoice
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice as ChatCompletionChunkChoice from openai.types.chat.chat_completion_chunk import (
Choice as ChatCompletionChunkChoice,
)
from pydantic import ValidationError from pydantic import ValidationError
from rich.console import Console from rich.console import Console
from rich.live import Live from rich.live import Live
@ -231,7 +239,8 @@ def get_tools_from_engine(
continue continue
except APIConnectionError: except APIConnectionError:
console.print( console.print(
f"❌ Can't connect to Arcade Engine at {base_url}. (Is it running?)", style="bold red" f"❌ Can't connect to Arcade Engine at {base_url}. (Is it running?)",
style="bold red",
) )
return tools return tools
@ -326,7 +335,8 @@ def validate_and_get_config(
if validate_user and (not config.user or not config.user.email): if validate_user and (not config.user or not config.user.email):
console.print( console.print(
"❌ User email not found in configuration. Please run `arcade login`.", style="bold red" "❌ User email not found in configuration. Please run `arcade login`.",
style="bold red",
) )
raise typer.Exit(code=1) raise typer.Exit(code=1)
@ -371,7 +381,11 @@ class ChatInteractionResult:
def handle_chat_interaction( def handle_chat_interaction(
client: OpenAI, model: str, history: list[dict], user_email: str | None, stream: bool = False client: OpenAI,
model: str,
history: list[dict],
user_email: str | None,
stream: bool = False,
) -> ChatInteractionResult: ) -> ChatInteractionResult:
""" """
Handle a single chat-request/chat-response interaction for both streamed and non-streamed responses. Handle a single chat-request/chat-response interaction for both streamed and non-streamed responses.
@ -418,7 +432,8 @@ def handle_chat_interaction(
elif role == "assistant": elif role == "assistant":
message_content = markdownify_urls(message_content) message_content = markdownify_urls(message_content)
console.print( console.print(
f"\n[blue][bold]Assistant[/bold] ({model}):[/blue] ", Markdown(message_content) f"\n[blue][bold]Assistant[/bold] ({model}):[/blue] ",
Markdown(message_content),
) )
else: else:
console.print(f"\n[bold]{role}:[/bold] {message_content}") console.print(f"\n[bold]{role}:[/bold] {message_content}")
@ -575,7 +590,10 @@ def load_eval_suites(eval_files: list[Path]) -> list[Callable]:
] ]
if not eval_suite_funcs: if not eval_suite_funcs:
console.print(f"No @tool_eval functions found in {eval_file_path}", style="bold yellow") console.print(
f"No @tool_eval functions found in {eval_file_path}",
style="bold yellow",
)
continue continue
eval_suites.extend(eval_suite_funcs) eval_suites.extend(eval_suite_funcs)
@ -628,7 +646,7 @@ def handle_user_command(
user_input: str, user_input: str,
history: list, history: list,
host: str, host: str,
port: int, port: int | None,
force_tls: bool, force_tls: bool,
force_no_tls: bool, force_no_tls: bool,
show: Callable, show: Callable,
@ -658,6 +676,7 @@ def handle_user_command(
force_tls=force_tls, force_tls=force_tls,
force_no_tls=force_no_tls, force_no_tls=force_no_tls,
debug=False, debug=False,
worker=False,
) )
return True return True
return False return False

View file

@ -4,7 +4,7 @@ import logging
import os import os
import re import re
import typing import typing
from collections.abc import Iterator from collections.abc import Callable, Iterator
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
@ -13,13 +13,12 @@ from types import ModuleType
from typing import ( from typing import (
Annotated, Annotated,
Any, Any,
Callable,
Literal, Literal,
Optional,
Union, Union,
cast, cast,
get_args, get_args,
get_origin, get_origin,
get_type_hints,
) )
from pydantic import BaseModel, Field, create_model from pydantic import BaseModel, Field, create_model
@ -62,6 +61,28 @@ InnerWireType = Literal["string", "integer", "number", "boolean", "json"]
WireType = Union[InnerWireType, Literal["array"]] WireType = Union[InnerWireType, Literal["array"]]
def is_typeddict(tp: type) -> bool:
"""
Check if a type is a TypedDict.
Works with both typing.TypedDict and typing_extensions.TypedDict.
"""
try:
# TypedDict creates classes that inherit from dict
if not isinstance(tp, type) or not issubclass(tp, dict):
return False
# Check for TypedDict-specific attributes
return (
hasattr(tp, "__annotations__")
and hasattr(tp, "__total__")
and hasattr(tp, "__required_keys__")
and hasattr(tp, "__optional_keys__")
)
except TypeError:
# Some special forms raise TypeError when checking issubclass
return False
@dataclass @dataclass
class WireTypeInfo: class WireTypeInfo:
""" """
@ -71,6 +92,9 @@ class WireTypeInfo:
wire_type: WireType wire_type: WireType
inner_wire_type: InnerWireType | None = None inner_wire_type: InnerWireType | None = None
enum_values: list[str] | None = None enum_values: list[str] | None = None
properties: dict[str, "WireTypeInfo"] | None = None
inner_properties: dict[str, "WireTypeInfo"] | None = None
description: str | None = None
class ToolMeta(BaseModel): class ToolMeta(BaseModel):
@ -79,9 +103,9 @@ class ToolMeta(BaseModel):
""" """
module: str module: str
toolkit: Optional[str] = None toolkit: str | None = None
package: Optional[str] = None package: str | None = None
path: Optional[str] = None path: str | None = None
date_added: datetime = Field(default_factory=datetime.now) date_added: datetime = Field(default_factory=datetime.now)
date_updated: datetime = Field(default_factory=datetime.now) date_updated: datetime = Field(default_factory=datetime.now)
@ -171,7 +195,7 @@ class ToolCatalog(BaseModel):
def add_tool( def add_tool(
self, self,
tool_func: Callable, tool_func: Callable,
toolkit_or_name: Union[str, Toolkit], toolkit_or_name: str | Toolkit,
module: ModuleType | None = None, module: ModuleType | None = None,
) -> None: ) -> None:
""" """
@ -289,7 +313,10 @@ class ToolCatalog(BaseModel):
raise ValueError(f"Tool {func} not found in the catalog.") raise ValueError(f"Tool {func} not found in the catalog.")
def get_tool_by_name( def get_tool_by_name(
self, name: str, version: Optional[str] = None, separator: str = TOOL_NAME_SEPARATOR self,
name: str,
version: str | None = None,
separator: str = TOOL_NAME_SEPARATOR,
) -> MaterializedTool: ) -> MaterializedTool:
"""Get a tool from the catalog by name. """Get a tool from the catalog by name.
@ -353,8 +380,8 @@ class ToolCatalog(BaseModel):
def create_tool_definition( def create_tool_definition(
tool: Callable, tool: Callable,
toolkit_name: str, toolkit_name: str,
toolkit_version: Optional[str] = None, toolkit_version: str | None = None,
toolkit_desc: Optional[str] = None, toolkit_desc: str | None = None,
) -> ToolDefinition: ) -> ToolDefinition:
""" """
Given a tool function, create a ToolDefinition Given a tool function, create a ToolDefinition
@ -431,16 +458,13 @@ def create_input_definition(func: Callable) -> ToolInput:
description=tool_field_info.description, description=tool_field_info.description,
required=is_required, required=is_required,
inferrable=tool_field_info.is_inferrable, inferrable=tool_field_info.is_inferrable,
value_schema=ValueSchema( value_schema=wire_type_info_to_value_schema(tool_field_info.wire_type_info),
val_type=tool_field_info.wire_type_info.wire_type,
inner_val_type=tool_field_info.wire_type_info.inner_wire_type,
enum=tool_field_info.wire_type_info.enum_values,
),
) )
) )
return ToolInput( return ToolInput(
parameters=input_parameters, tool_context_parameter_name=tool_context_param_name parameters=input_parameters,
tool_context_parameter_name=tool_context_param_name,
) )
@ -478,11 +502,7 @@ def create_output_definition(func: Callable) -> ToolOutput:
return ToolOutput( return ToolOutput(
description=description, description=description,
available_modes=available_modes, available_modes=available_modes,
value_schema=ValueSchema( value_schema=wire_type_info_to_value_schema(wire_type_info),
val_type=wire_type_info.wire_type,
inner_val_type=wire_type_info.inner_wire_type,
enum=wire_type_info.enum_values,
),
) )
@ -669,12 +689,21 @@ def get_wire_type_info(_type: type) -> WireTypeInfo:
# Is this a list type? # Is this a list type?
# If so, get the inner (enclosed) type # If so, get the inner (enclosed) type
is_list = get_origin(_type) is list is_list = get_origin(_type) is list
inner_properties = None
if is_list: if is_list:
inner_type = get_args(_type)[0] inner_type = get_args(_type)[0]
inner_wire_type = cast(
InnerWireType, # Recursively get wire type info for inner type
get_wire_type(str) if is_string_literal(inner_type) else get_wire_type(inner_type), inner_info = get_wire_type_info(inner_type)
) inner_wire_type = cast(InnerWireType, inner_info.wire_type)
# If inner type has properties (it's a complex object), propagate them
if inner_info.properties:
inner_properties = inner_info.properties
# If inner type is array (nested arrays), propagate inner_properties
elif inner_info.inner_properties:
inner_properties = inner_info.inner_properties
else: else:
inner_wire_type = None inner_wire_type = None
@ -696,11 +725,133 @@ def get_wire_type_info(_type: type) -> WireTypeInfo:
enum_values = [str(e) for e in get_args(type_to_check)] enum_values = [str(e) for e in get_args(type_to_check)]
# Special case: Enum can be enumerated on the wire # Special case: Enum can be enumerated on the wire
elif issubclass(actual_type, Enum): elif isinstance(actual_type, type) and issubclass(actual_type, Enum):
is_enum = True is_enum = True
enum_values = [e.value for e in actual_type] # type: ignore[union-attr] enum_values = [e.value for e in actual_type]
return WireTypeInfo(wire_type, inner_wire_type, enum_values if is_enum else None) # Extract properties for complex types
properties = None
if wire_type == "json" and not is_list:
properties = extract_properties(type_to_check)
return WireTypeInfo(
wire_type,
inner_wire_type,
enum_values if is_enum else None,
properties,
inner_properties,
)
def _extract_typeddict_field_descriptions(typeddict_class: type) -> dict[str, str]:
"""
Extract field descriptions from TypedDict docstrings.
TypedDict classes typically have field descriptions as docstrings after each field.
This function attempts to parse the source code to extract these descriptions.
"""
descriptions = {}
try:
source = inspect.getsource(typeddict_class)
# Simple regex to match field: type pattern followed by a docstring
# This is a simplified approach - a full AST parser would be more robust
import re
# Pattern to match field definition followed by docstring
pattern = r'(\w+):\s*[^"\n]+\n\s*"""([^"]+)"""'
matches = re.findall(pattern, source)
for field_name, description in matches:
descriptions[field_name] = description.strip()
except (OSError, TypeError):
# If we can't get the source, return empty descriptions
pass
return descriptions
def extract_properties(type_to_check: type) -> dict[str, WireTypeInfo] | None:
"""
Extract properties from TypedDict, Pydantic models, or other structured types.
"""
properties = {}
# Handle Pydantic BaseModel
if isinstance(type_to_check, type) and issubclass(type_to_check, BaseModel):
for field_name, field_info in type_to_check.model_fields.items():
# Get the field type
field_type = field_info.annotation
if field_type is None:
continue
# Handle Optional types (Union[T, None])
if is_strict_optional(field_type):
# Extract the non-None type from Optional
field_type = next(arg for arg in get_args(field_type) if arg is not type(None))
# Get wire type info recursively
wire_info = get_wire_type_info(field_type)
properties[field_name] = wire_info
# Handle TypedDict
elif is_typeddict(type_to_check):
# Get type hints for the TypedDict
type_hints = get_type_hints(type_to_check, include_extras=True)
# Try to extract field descriptions from the class source
field_descriptions = _extract_typeddict_field_descriptions(type_to_check)
for field_name, field_type in type_hints.items():
# Handle Optional types (Union[T, None])
if is_strict_optional(field_type):
# Extract the non-None type from Optional
field_type = next(arg for arg in get_args(field_type) if arg is not type(None))
wire_info = get_wire_type_info(field_type)
# Add description if available
if field_name in field_descriptions:
wire_info.description = field_descriptions[field_name]
properties[field_name] = wire_info
# Handle regular dict with type annotations (e.g., dict[str, Any])
elif get_origin(type_to_check) is dict:
# For generic dicts, we can't extract specific properties
return None
return properties if properties else None
def wire_type_info_to_value_schema(wire_info: WireTypeInfo) -> ValueSchema:
"""
Convert WireTypeInfo to ValueSchema, including nested properties.
"""
# Convert nested properties if they exist
properties = None
if wire_info.properties:
properties = {
name: wire_type_info_to_value_schema(nested_info)
for name, nested_info in wire_info.properties.items()
}
# Convert inner properties for array items
inner_properties = None
if wire_info.inner_properties:
inner_properties = {
name: wire_type_info_to_value_schema(nested_info)
for name, nested_info in wire_info.inner_properties.items()
}
return ValueSchema(
val_type=wire_info.wire_type,
inner_val_type=wire_info.inner_wire_type,
enum=wire_info.enum_values,
properties=properties,
inner_properties=inner_properties,
description=wire_info.description,
)
def extract_python_param_info(param: inspect.Parameter) -> ParamInfo: def extract_python_param_info(param: inspect.Parameter) -> ParamInfo:
@ -799,6 +950,9 @@ def get_wire_type(
if isinstance(_type, type) and issubclass(_type, BaseModel): if isinstance(_type, type) and issubclass(_type, BaseModel):
return "json" return "json"
if is_typeddict(_type):
return "json"
raise ToolDefinitionError(f"Unsupported parameter type: {_type}") raise ToolDefinitionError(f"Unsupported parameter type: {_type}")
@ -831,7 +985,7 @@ def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]
return input_model, output_model return input_model, output_model
def determine_output_model(func: Callable) -> type[BaseModel]: def determine_output_model(func: Callable) -> type[BaseModel]: # noqa: C901
""" """
Determine the output model for a function based on its return annotation. Determine the output model for a function based on its return annotation.
""" """
@ -845,6 +999,18 @@ def determine_output_model(func: Callable) -> type[BaseModel]:
description = ( description = (
return_annotation.__metadata__[0] if return_annotation.__metadata__ else "" return_annotation.__metadata__[0] if return_annotation.__metadata__ else ""
) )
# Check if the field type is a TypedDict
if is_typeddict(field_type):
# Create a Pydantic model from TypedDict
typeddict_model = create_model_from_typeddict(
field_type, f"{output_model_name}TypedDict"
)
return create_model(
output_model_name,
result=(typeddict_model, Field(description=str(description))),
)
if description: if description:
return create_model( return create_model(
output_model_name, output_model_name,
@ -857,6 +1023,18 @@ def determine_output_model(func: Callable) -> type[BaseModel]:
# TODO handle multiple non-None arguments. Raise error? # TODO handle multiple non-None arguments. Raise error?
for arg in get_args(return_annotation): for arg in get_args(return_annotation):
if arg is not type(None): if arg is not type(None):
# Check if the arg is a TypedDict
if is_typeddict(arg):
typeddict_model = create_model_from_typeddict(
arg, f"{output_model_name}TypedDict"
)
return create_model(
output_model_name,
result=(
typeddict_model,
Field(description="No description provided."),
),
)
return create_model( return create_model(
output_model_name, output_model_name,
result=(arg, Field(description="No description provided.")), result=(arg, Field(description="No description provided.")),
@ -871,6 +1049,17 @@ def determine_output_model(func: Callable) -> type[BaseModel]:
), ),
) )
else: else:
# Check if return type is TypedDict
if is_typeddict(return_annotation):
typeddict_model = create_model_from_typeddict(return_annotation, output_model_name)
return create_model(
output_model_name,
result=(
typeddict_model,
Field(description="No description provided."),
),
)
# Handle simple return types (like str) # Handle simple return types (like str)
return create_model( return create_model(
output_model_name, output_model_name,
@ -878,6 +1067,37 @@ def determine_output_model(func: Callable) -> type[BaseModel]:
) )
def create_model_from_typeddict(typeddict_class: type, model_name: str) -> type[BaseModel]:
"""
Create a Pydantic model from a TypedDict class.
This enables runtime validation of TypedDict structures.
"""
# Get type hints for the TypedDict
type_hints = get_type_hints(typeddict_class, include_extras=True)
# Build field definitions for the Pydantic model
field_definitions: dict[str, Any] = {}
for field_name, field_type in type_hints.items():
# Check if field is required
is_required = field_name in getattr(typeddict_class, "__required_keys__", set())
# Handle nested TypedDict
if is_typeddict(field_type):
nested_model = create_model_from_typeddict(field_type, f"{model_name}_{field_name}")
if is_required:
field_definitions[field_name] = (nested_model, Field())
else:
field_definitions[field_name] = (nested_model, Field(default=None))
else:
if is_required:
field_definitions[field_name] = (field_type, Field())
else:
field_definitions[field_name] = (field_type, Field(default=None))
# Create and return the Pydantic model
return create_model(model_name, **field_definitions)
def to_tool_secret_requirements( def to_tool_secret_requirements(
secrets_requirement: list[str], secrets_requirement: list[str],
) -> list[ToolSecretRequirement]: ) -> list[ToolSecretRequirement]:

View file

@ -1,6 +1,7 @@
import asyncio import asyncio
import traceback import traceback
from typing import Any, Callable from collections.abc import Callable
from typing import Any
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
@ -12,7 +13,12 @@ from arcade_core.errors import (
ToolSerializationError, ToolSerializationError,
) )
from arcade_core.output import output_factory from arcade_core.output import output_factory
from arcade_core.schema import ToolCallLog, ToolCallOutput, ToolContext, ToolDefinition from arcade_core.schema import (
ToolCallLog,
ToolCallOutput,
ToolContext,
ToolDefinition,
)
class ToolExecutor: class ToolExecutor:
@ -34,7 +40,9 @@ class ToolExecutor:
if definition.deprecation_message is not None: if definition.deprecation_message is not None:
tool_call_logs.append( tool_call_logs.append(
ToolCallLog( ToolCallLog(
message=definition.deprecation_message, level="warning", subtype="deprecation" message=definition.deprecation_message,
level="warning",
subtype="deprecation",
) )
) )
@ -101,7 +109,8 @@ class ToolExecutor:
except ValidationError as e: except ValidationError as e:
raise ToolInputError( raise ToolInputError(
message="Error in tool input deserialization", developer_message=str(e) message="Error in tool input deserialization",
developer_message=str(e),
) from e ) from e
return inputs return inputs

View file

@ -1,5 +1,7 @@
from typing import TypeVar from typing import TypeVar
from pydantic import BaseModel
from arcade_core.schema import ToolCallError, ToolCallLog, ToolCallOutput from arcade_core.schema import ToolCallError, ToolCallLog, ToolCallOutput
from arcade_core.utils import coerce_empty_list_to_none from arcade_core.utils import coerce_empty_list_to_none
@ -17,9 +19,29 @@ class ToolOutputFactory:
data: T | None = None, data: T | None = None,
logs: list[ToolCallLog] | None = None, logs: list[ToolCallLog] | None = None,
) -> ToolCallOutput: ) -> ToolCallOutput:
value = getattr(data, "result", "") if data else "" # Extract the result value
"""
Extracts the result value for the tool output.
The executor guarantees that `data` is either a string, a dict, or None.
"""
value: str | int | float | bool | dict | list[str] | None
if data is None:
value = ""
elif hasattr(data, "result"):
value = getattr(data, "result", "")
elif isinstance(data, BaseModel):
value = data.model_dump()
elif isinstance(data, (str, int, float, bool, list)):
value = data
else:
raise ValueError(f"Unsupported data output type: {type(data)}")
logs = coerce_empty_list_to_none(logs) logs = coerce_empty_list_to_none(logs)
return ToolCallOutput(value=value, logs=logs) return ToolCallOutput(
value=value,
logs=logs,
)
def fail( def fail(
self, self,
@ -56,6 +78,7 @@ class ToolOutputFactory:
can_retry=True, can_retry=True,
additional_prompt_content=additional_prompt_content, additional_prompt_content=additional_prompt_content,
retry_after_ms=retry_after_ms, retry_after_ms=retry_after_ms,
traceback_info=traceback_info,
), ),
logs=coerce_empty_list_to_none(logs), logs=coerce_empty_list_to_none(logs),
) )

View file

@ -1,6 +1,5 @@
import ast import ast
from pathlib import Path from pathlib import Path
from typing import Optional, Union
def load_ast_tree(filepath: str | Path) -> ast.AST: def load_ast_tree(filepath: str | Path) -> ast.AST:
@ -16,8 +15,8 @@ def load_ast_tree(filepath: str | Path) -> ast.AST:
def get_function_name_if_decorated( def get_function_name_if_decorated(
node: Union[ast.FunctionDef, ast.AsyncFunctionDef], node: ast.FunctionDef | ast.AsyncFunctionDef,
) -> Optional[str]: ) -> str | None:
""" """
Check if a function has a decorator. Check if a function has a decorator.
""" """

View file

@ -1,7 +1,7 @@
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Any, Literal, Optional, Union from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -15,12 +15,21 @@ class ValueSchema(BaseModel):
val_type: Literal["string", "integer", "number", "boolean", "json", "array"] val_type: Literal["string", "integer", "number", "boolean", "json", "array"]
"""The type of the value.""" """The type of the value."""
inner_val_type: Optional[Literal["string", "integer", "number", "boolean", "json"]] = None inner_val_type: Literal["string", "integer", "number", "boolean", "json"] | None = None
"""The type of the inner value, if the value is a list.""" """The type of the inner value, if the value is a list."""
enum: Optional[list[str]] = None enum: list[str] | None = None
"""The list of possible values for the value, if it is a closed list.""" """The list of possible values for the value, if it is a closed list."""
properties: dict[str, "ValueSchema"] | None = None
"""For object types (json), the schema of nested properties."""
inner_properties: dict[str, "ValueSchema"] | None = None
"""For array types with json items, the schema of properties for each array item."""
description: str | None = None
"""Optional description of the value."""
class InputParameter(BaseModel): class InputParameter(BaseModel):
"""A parameter that can be passed to a tool.""" """A parameter that can be passed to a tool."""
@ -30,8 +39,9 @@ class InputParameter(BaseModel):
..., ...,
description="Whether this parameter is required (true) or optional (false).", description="Whether this parameter is required (true) or optional (false).",
) )
description: Optional[str] = Field( description: str | None = Field(
None, description="A descriptive, human-readable explanation of the parameter." None,
description="A descriptive, human-readable explanation of the parameter.",
) )
value_schema: ValueSchema = Field( value_schema: ValueSchema = Field(
..., ...,
@ -59,14 +69,14 @@ class ToolInput(BaseModel):
class ToolOutput(BaseModel): class ToolOutput(BaseModel):
"""The output of a tool.""" """The output of a tool."""
description: Optional[str] = Field( description: str | None = Field(
None, description="A descriptive, human-readable explanation of the output." None, description="A descriptive, human-readable explanation of the output."
) )
available_modes: list[str] = Field( available_modes: list[str] = Field(
default_factory=lambda: ["value", "error", "null"], default_factory=lambda: ["value", "error", "null"],
description="The available modes for the output.", description="The available modes for the output.",
) )
value_schema: Optional[ValueSchema] = Field( value_schema: ValueSchema | None = Field(
None, description="The schema of the value of the output." None, description="The schema of the value of the output."
) )
@ -74,7 +84,7 @@ class ToolOutput(BaseModel):
class OAuth2Requirement(BaseModel): class OAuth2Requirement(BaseModel):
"""Indicates that the tool requires OAuth 2.0 authorization.""" """Indicates that the tool requires OAuth 2.0 authorization."""
scopes: Optional[list[str]] = None scopes: list[str] | None = None
"""The scope(s) needed for the authorized action.""" """The scope(s) needed for the authorized action."""
@ -90,16 +100,16 @@ class ToolAuthRequirement(BaseModel):
# #
# The Arcade SDK translates these into the appropriate provider ID (Google) and type (OAuth2). # The Arcade SDK translates these into the appropriate provider ID (Google) and type (OAuth2).
# The only time the developer will set these is if they are using a custom auth provider. # The only time the developer will set these is if they are using a custom auth provider.
provider_id: Optional[str] = None provider_id: str | None = None
"""The provider ID configured in Arcade that acts as an alias to well-known configuration.""" """The provider ID configured in Arcade that acts as an alias to well-known configuration."""
provider_type: str provider_type: str
"""The type of the authorization provider.""" """The type of the authorization provider."""
id: Optional[str] = None id: str | None = None
"""A provider's unique identifier, allowing the tool to specify a specific authorization provider. Recommended for private tools only.""" """A provider's unique identifier, allowing the tool to specify a specific authorization provider. Recommended for private tools only."""
oauth2: Optional[OAuth2Requirement] = None oauth2: OAuth2Requirement | None = None
"""The OAuth 2.0 requirement, if any.""" """The OAuth 2.0 requirement, if any."""
@ -133,13 +143,13 @@ class ToolMetadataRequirement(BaseModel):
class ToolRequirements(BaseModel): class ToolRequirements(BaseModel):
"""The requirements for a tool to run.""" """The requirements for a tool to run."""
authorization: Union[ToolAuthRequirement, None] = None authorization: ToolAuthRequirement | None = None
"""The authorization requirements for the tool, if any.""" """The authorization requirements for the tool, if any."""
secrets: Union[list[ToolSecretRequirement], None] = None secrets: list[ToolSecretRequirement] | None = None
"""The secret requirements for the tool, if any.""" """The secret requirements for the tool, if any."""
metadata: Union[list[ToolMetadataRequirement], None] = None metadata: list[ToolMetadataRequirement] | None = None
"""The metadata requirements for the tool, if any.""" """The metadata requirements for the tool, if any."""
@ -149,10 +159,10 @@ class ToolkitDefinition(BaseModel):
name: str name: str
"""The name of the toolkit.""" """The name of the toolkit."""
description: Optional[str] = None description: str | None = None
"""The description of the toolkit.""" """The description of the toolkit."""
version: Optional[str] = None version: str | None = None
"""The version identifier of the toolkit.""" """The version identifier of the toolkit."""
@ -166,7 +176,7 @@ class FullyQualifiedName:
toolkit_name: str toolkit_name: str
"""The name of the toolkit containing the tool.""" """The name of the toolkit containing the tool."""
toolkit_version: Optional[str] = None toolkit_version: str | None = None
"""The version of the toolkit containing the tool.""" """The version of the toolkit containing the tool."""
def __str__(self) -> str: def __str__(self) -> str:
@ -225,7 +235,7 @@ class ToolDefinition(BaseModel):
requirements: ToolRequirements requirements: ToolRequirements
"""The requirements (e.g. authorization) for the tool to run.""" """The requirements (e.g. authorization) for the tool to run."""
deprecation_message: Optional[str] = None deprecation_message: str | None = None
"""The message to display when the tool is deprecated.""" """The message to display when the tool is deprecated."""
def get_fully_qualified_name(self) -> FullyQualifiedName: def get_fully_qualified_name(self) -> FullyQualifiedName:
@ -241,7 +251,7 @@ class ToolReference(BaseModel):
toolkit: str toolkit: str
"""The name of the toolkit containing the tool.""" """The name of the toolkit containing the tool."""
version: Optional[str] = None version: str | None = None
"""The version of the toolkit containing the tool.""" """The version of the toolkit containing the tool."""
def get_fully_qualified_name(self) -> FullyQualifiedName: def get_fully_qualified_name(self) -> FullyQualifiedName:
@ -313,7 +323,10 @@ class ToolContext(BaseModel):
return self._get_item(key, self.metadata, "metadata") return self._get_item(key, self.metadata, "metadata")
def _get_item( def _get_item(
self, key: str, items: list[ToolMetadataItem] | list[ToolSecretItem] | None, item_name: str self,
key: str,
items: list[ToolMetadataItem] | list[ToolSecretItem] | None,
item_name: str,
) -> str: ) -> str:
if not key or not key.strip(): if not key or not key.strip():
raise ValueError( raise ValueError(
@ -368,7 +381,7 @@ class ToolCallLog(BaseModel):
] ]
"""The level of severity for the log.""" """The level of severity for the log."""
subtype: Optional[Literal["deprecation"]] = None subtype: Literal["deprecation"] | None = None
"""Optional field for further categorization of the log.""" """Optional field for further categorization of the log."""
@ -405,7 +418,7 @@ class ToolCallRequiresAuthorization(BaseModel):
class ToolCallOutput(BaseModel): class ToolCallOutput(BaseModel):
"""The output of a tool invocation.""" """The output of a tool invocation."""
value: Union[str, int, float, bool, dict, list[str]] | None = None value: str | int | float | bool | dict | list[str] | None = None
"""The value returned by the tool.""" """The value returned by the tool."""
logs: list[ToolCallLog] | None = None logs: list[ToolCallLog] | None = None
"""The logs that occurred during the tool invocation.""" """The logs that occurred during the tool invocation."""

View file

@ -3,9 +3,9 @@ from __future__ import annotations
import ast import ast
import inspect import inspect
import re import re
from collections.abc import Iterable from collections.abc import Callable, Iterable
from types import UnionType from types import UnionType
from typing import Any, Callable, Literal, TypeVar, Union, get_args, get_origin from typing import Any, Literal, TypeVar, Union, get_args, get_origin
T = TypeVar("T") T = TypeVar("T")

View file

@ -82,17 +82,17 @@ class HealthCheckComponent(WorkerComponent):
"health", "health",
self, self,
method="GET", method="GET",
require_auth=False,
response_type=HealthCheckResponse, response_type=HealthCheckResponse,
operation_id="health_check", operation_id="health_check",
description="Check the health of the worker", description="Health check",
summary="Check the health of the worker", summary="Health check",
tags=["Arcade"], tags=["Arcade"],
require_auth=False,
) )
async def __call__(self, request: RequestData) -> HealthCheckResponse: async def __call__(self, request: RequestData) -> HealthCheckResponse:
""" """
Handle the request for a health check. Handle the request to check the health of the worker.
""" """
tracer = trace.get_tracer(__name__) tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("HealthCheck"): with tracer.start_as_current_span("HealthCheck"):

View file

@ -14,6 +14,7 @@ def test_show_logic_local_false():
port=None, port=None,
force_tls=False, force_tls=False,
force_no_tls=False, force_no_tls=False,
worker=False,
debug=False, debug=False,
) )
@ -33,6 +34,7 @@ def test_show_logic_local_true():
port=None, port=None,
force_tls=False, force_tls=False,
force_no_tls=False, force_no_tls=False,
worker=False,
debug=False, debug=False,
) )

View file

@ -179,7 +179,7 @@ def check_output(output: ToolCallOutput, expected_output: ToolCallOutput):
output_logs = output.logs or [] output_logs = output.logs or []
expected_logs = expected_output.logs or [] expected_logs = expected_output.logs or []
assert len(output_logs) == len(expected_logs) assert len(output_logs) == len(expected_logs)
for output_log, expected_log in zip(output_logs, expected_logs): for output_log, expected_log in zip(output_logs, expected_logs, strict=False):
assert output_log.message == expected_log.message assert output_log.message == expected_log.message
assert output_log.level == expected_log.level assert output_log.level == expected_log.level
assert output_log.subtype == expected_log.subtype assert output_log.subtype == expected_log.subtype

View file

@ -1,5 +1,5 @@
from enum import Enum from enum import Enum
from typing import Annotated, Literal, Optional, Union from typing import Annotated, Literal
import pytest import pytest
from arcade_core.catalog import ToolCatalog from arcade_core.catalog import ToolCatalog
@ -186,20 +186,20 @@ def func_with_param_with_default(param1: Annotated[str, "First param"] = "defaul
@tool(desc="A function with an optional input parameter") @tool(desc="A function with an optional input parameter")
def func_with_optional_param(param1: Annotated[Optional[str], "First param"]): def func_with_optional_param(param1: Annotated[str | None, "First param"]):
pass pass
@tool(desc="A function with an optional input parameter (default: None)") @tool(desc="A function with an optional input parameter (default: None)")
def func_with_optional_param_with_default_None( def func_with_optional_param_with_default_None(
param1: Annotated[Optional[str], "First param"] = None, param1: Annotated[str | None, "First param"] = None,
): ):
pass pass
@tool(desc="A function with an optional input parameter with default value") @tool(desc="A function with an optional input parameter with default value")
def func_with_optional_param_with_default_value( def func_with_optional_param_with_default_value(
param1: Annotated[Optional[str], "First param"] = "default", param1: Annotated[str | None, "First param"] = "default",
): ):
pass pass
@ -220,14 +220,14 @@ def func_with_optional_param_with_bar_syntax_2(
@tool(desc="A function with an optional input parameter with union syntax") @tool(desc="A function with an optional input parameter with union syntax")
def func_with_optional_param_with_union_syntax_1( def func_with_optional_param_with_union_syntax_1(
param1: Annotated[Union[str, None], "First param"] = None, param1: Annotated[str | None, "First param"] = None,
): ):
pass pass
@tool(desc="A function with an optional input parameter with union syntax") @tool(desc="A function with an optional input parameter with union syntax")
def func_with_optional_param_with_union_syntax_2( def func_with_optional_param_with_union_syntax_2(
param1: Annotated[Union[None, str], "First param"] = None, param1: Annotated[None | str, "First param"] = None,
): ):
pass pass
@ -290,7 +290,7 @@ def func_with_annotated_return() -> Annotated[str, "Annotated return description
@tool(desc="A function with an optional return type") @tool(desc="A function with an optional return type")
def func_with_optional_return() -> Optional[str]: def func_with_optional_return() -> str | None:
return "maybe output" return "maybe output"
@ -305,12 +305,12 @@ def func_with_optional_return_with_bar_syntax_2() -> None | str:
@tool(desc="A function with an optional return type that uses union syntax") @tool(desc="A function with an optional return type that uses union syntax")
def func_with_optional_return_with_union_syntax_1() -> Union[str, None]: def func_with_optional_return_with_union_syntax_1() -> str | None:
return "maybe output" return "maybe output"
@tool(desc="A function with an optional return type that uses union syntax") @tool(desc="A function with an optional return type that uses union syntax")
def func_with_optional_return_with_union_syntax_2() -> Union[None, str]: def func_with_optional_return_with_union_syntax_2() -> None | str:
return "maybe output" return "maybe output"

View file

@ -1,4 +1,4 @@
from typing import Annotated, Union from typing import Annotated
import pytest import pytest
from arcade_core.catalog import ToolCatalog from arcade_core.catalog import ToolCatalog
@ -18,7 +18,7 @@ def func_with_missing_return_type():
@tool(desc="A function with a union return type (illegal)") @tool(desc="A function with a union return type (illegal)")
def func_with_union_return_type_1() -> Union[str, int]: def func_with_union_return_type_1() -> str | int:
return "hello world" return "hello world"
@ -48,7 +48,7 @@ def func_with_union_param_1(param1: str | int):
@tool(desc="A function with a union parameter (illegal)") @tool(desc="A function with a union parameter (illegal)")
def func_with_union_param_2(param1: Union[str, int]): def func_with_union_param_2(param1: str | int):
pass pass

View file

@ -1,4 +1,4 @@
from typing import Annotated, Optional, Union from typing import Annotated
import pytest import pytest
from arcade_core.catalog import ToolCatalog from arcade_core.catalog import ToolCatalog
@ -12,15 +12,33 @@ from arcade_tdk import tool
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class ProductOutput(BaseModel): class ProductOutputModel(BaseModel):
product_name: str = Field(..., description="The name of the product") product_name: str
price: int = Field(..., description="The price of the product") """The name of the product"""
stock_quantity: int = Field(..., description="The stock quantity of the product") price: int
"""The price of the product"""
stock_quantity: int
"""The stock quantity of the product"""
class Config:
extra = "forbid"
@tool(desc="A function that returns a Pydantic model") @tool(desc="A function that returns a Pydantic model")
def func_returns_pydantic_model() -> Annotated[ProductOutput, "The product, price, and quantity"]: def func_returns_pydantic_model() -> Annotated[
return ProductOutput( ProductOutputModel, "The product, price, and quantity"
]:
"""
Returns a ProductOutput Pydantic model with sample data.
Returns:
ProductOutput: The product, price, and quantity.
Example:
>>> func_returns_pydantic_model()
ProductOutput(product_name='Product 1', price=100, stock_quantity=1000)
"""
return ProductOutputModel(
product_name="Product 1", product_name="Product 1",
price=100, price=100,
stock_quantity=1000, stock_quantity=1000,
@ -36,23 +54,23 @@ def func_takes_pydantic_field_with_description(
@tool(desc="A function that accepts an optional Pydantic Field") @tool(desc="A function that accepts an optional Pydantic Field")
def func_takes_pydantic_field_optional( def func_takes_pydantic_field_optional(
product_name: Optional[str] = Field(None, description="The name of the product"), product_name: str | None = Field(None, description="The name of the product"),
) -> str: ) -> str:
return product_name return product_name if product_name is not None else "Product 1"
@tool(desc="A function that accepts an optional Pydantic Field with bar syntax") @tool(desc="A function that accepts an optional Pydantic Field with bar syntax")
def func_takes_pydantic_field_optional_bar_syntax( def func_takes_pydantic_field_optional_bar_syntax(
product_name: str | None = Field(None, description="The name of the product"), product_name: str | None = Field(None, description="The name of the product"),
) -> str: ) -> str | None:
return product_name return product_name if product_name is not None else None
@tool(desc="A function that accepts an optional Pydantic Field with union syntax") @tool(desc="A function that accepts an optional Pydantic Field with union syntax")
def func_takes_pydantic_field_optional_union_syntax( def func_takes_pydantic_field_optional_union_syntax(
product_name: Union[str, None] = Field(None, description="The name of the product"), product_name: str | None = Field(None, description="The name of the product"),
) -> str: ) -> str:
return product_name return product_name if product_name is not None else "Product 1"
# Annotated[] takes precedence over Field() properties # Annotated[] takes precedence over Field() properties
@ -85,9 +103,22 @@ def func_takes_pydantic_field_default(
@tool(desc="A function that accepts a Pydantic Field with a default value factory") @tool(desc="A function that accepts a Pydantic Field with a default value factory")
def func_takes_pydantic_field_default_factory( def func_takes_pydantic_field_default_factory(
product_name: str = Field( product_name: str = Field(
..., description="The name of the product", default_factory=lambda: "Product 1" default_factory=lambda: "Product 1", description="The name of the product"
), ),
) -> str: ) -> str:
"""
Accepts a product name with a default value provided by a factory.
Parameters:
product_name: The name of the product. Defaults to "Product 1" if not provided.
Returns:
str: The product name.
Example:
>>> func_takes_pydantic_field_default_factory()
'Product 1'
"""
return product_name return product_name
@ -114,9 +145,18 @@ class FilterPriceLessThan(ProductFilter):
class ProductSearch(BaseModel): class ProductSearch(BaseModel):
column: str = Field("Product Name", description="The column to search in") column: str = Field(..., description="The column to search in")
query: str = Field(..., description="The query to search for") query: str = Field(..., description="The query to search for")
filter_operation: Union[FilterRating, FilterPriceGreaterThan, FilterPriceLessThan] = None filter_operation: FilterRating | None = Field(
default=None,
description="The filter operation to apply (rating or price filter).",
)
highest_price: FilterPriceGreaterThan | None = Field(
default=None, description="The highest price to filter by"
)
lowest_price: FilterPriceLessThan | None = Field(
default=None, description="The lowest price to filter by"
)
class ProductOutput(BaseModel): class ProductOutput(BaseModel):
@ -129,14 +169,31 @@ class ProductOutput(BaseModel):
def read_products( def read_products(
action: Annotated[ProductSearch, "The search query to perform"], action: Annotated[ProductSearch, "The search query to perform"],
cols: list[str] = Field( cols: list[str] = Field(
...,
description="The columns to return",
default_factory=lambda: ["Product Name", "Price", "Stock Quantity"], default_factory=lambda: ["Product Name", "Price", "Stock Quantity"],
description="The columns to return",
), ),
) -> Annotated[list[ProductOutput], "Data with the selected columns"]: ) -> Annotated[list[ProductOutput], "Data with the selected columns"]:
"""Used to search through products by name and filter by rating or price.""" """
Used to search through products by name and filter by rating or price.
pass Parameters:
action: The search query to perform, as a ProductSearch model.
cols: The columns to return. Defaults to ["Product Name", "Price", "Stock Quantity"].
Returns:
list[ProductOutput]: Data with the selected columns.
Raises:
None
Example:
>>> await read_products(ProductSearch(query="Widget"), ["Product Name", "Price"])
"""
# This is a stub implementation for testing; in real code, this would query a database or service.
return [
ProductOutput(product_name="Widget", price=100, stock_quantity=50),
ProductOutput(product_name="Gadget", price=150, stock_quantity=20),
]
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -146,7 +203,15 @@ def read_products(
func_returns_pydantic_model, func_returns_pydantic_model,
{ {
"output": ToolOutput( "output": ToolOutput(
value_schema=ValueSchema(val_type="json", enum=None), value_schema=ValueSchema(
val_type="json",
enum=None,
properties={
"product_name": ValueSchema(val_type="string", enum=None),
"price": ValueSchema(val_type="integer", enum=None),
"stock_quantity": ValueSchema(val_type="integer", enum=None),
},
),
available_modes=["value", "error"], available_modes=["value", "error"],
description="The product, price, and quantity", description="The product, price, and quantity",
) )
@ -299,12 +364,46 @@ def read_products(
description="The search query to perform", description="The search query to perform",
required=True, required=True,
inferrable=True, inferrable=True,
value_schema=ValueSchema(val_type="json", enum=None), value_schema=ValueSchema(
val_type="json",
enum=None,
properties={
"column": ValueSchema(val_type="string", enum=None),
"query": ValueSchema(val_type="string", enum=None),
"filter_operation": ValueSchema(
val_type="json",
enum=None,
properties={
"column": ValueSchema(val_type="string", enum=None),
"greater_than": ValueSchema(
val_type="integer", enum=None
),
},
),
"highest_price": ValueSchema(
val_type="json",
enum=None,
properties={
"column": ValueSchema(val_type="string", enum=None),
"price": ValueSchema(val_type="integer", enum=None),
},
),
"lowest_price": ValueSchema(
val_type="json",
enum=None,
properties={
"column": ValueSchema(val_type="string", enum=None),
"price": ValueSchema(val_type="integer", enum=None),
},
),
},
),
), ),
InputParameter( InputParameter(
name="cols", name="cols",
description="The columns to return", description="The columns to return",
required=False, required=False,
inferrable=True,
value_schema=ValueSchema( value_schema=ValueSchema(
val_type="array", inner_val_type="string", enum=None val_type="array", inner_val_type="string", enum=None
), ),
@ -312,7 +411,16 @@ def read_products(
] ]
), ),
"output": ToolOutput( "output": ToolOutput(
value_schema=ValueSchema(val_type="array", inner_val_type="json", enum=None), value_schema=ValueSchema(
val_type="array",
inner_val_type="json",
enum=None,
inner_properties={
"product_name": ValueSchema(val_type="string", enum=None),
"price": ValueSchema(val_type="integer", enum=None),
"stock_quantity": ValueSchema(val_type="integer", enum=None),
},
),
available_modes=["value", "error"], available_modes=["value", "error"],
description="Data with the selected columns", description="Data with the selected columns",
), ),

View file

@ -1,4 +1,4 @@
from typing import Annotated, Union from typing import Annotated
import pytest import pytest
from arcade_core.catalog import ToolCatalog from arcade_core.catalog import ToolCatalog
@ -29,7 +29,7 @@ def func_takes_pydantic_field_non_strict_optional_bar_syntax(
@tool(desc="A function that accepts an optional Pydantic Field with non-strict optional syntax") @tool(desc="A function that accepts an optional Pydantic Field with non-strict optional syntax")
def func_takes_pydantic_field_non_strict_optional_union_syntax( def func_takes_pydantic_field_non_strict_optional_union_syntax(
product_name: Union[str, int, None] = Field(None, description="The name of the product"), product_name: str | int | None = Field(None, description="The name of the product"),
) -> str: ) -> str:
return product_name return product_name

View file

@ -0,0 +1,323 @@
from typing import Annotated
import pytest
from arcade_core.catalog import ToolCatalog
from arcade_core.schema import (
InputParameter,
ToolInput,
ToolOutput,
ValueSchema,
)
from arcade_tdk import tool
from typing_extensions import TypedDict
class ProductOutputDict(TypedDict):
"""A product with its details."""
product_name: str
price: int
stock_quantity: int
@tool(desc="A function that returns a TypedDict")
def func_returns_typeddict() -> Annotated[ProductOutputDict, "The product, price, and quantity"]:
"""Returns a ProductOutput TypedDict with sample data."""
return ProductOutputDict(
product_name="Product 1",
price=100,
stock_quantity=1000,
)
@tool(desc="A function that returns a list of TypedDict")
def func_returns_list_of_typeddict() -> Annotated[
list[ProductOutputDict], "The product, price, and quantity"
]:
"""Returns a list of ProductOutput TypedDict with sample data."""
return [
ProductOutputDict(
product_name="Product 1",
price=100,
stock_quantity=1000,
),
ProductOutputDict(
product_name="Product 2",
price=200,
stock_quantity=2000,
),
]
@tool(desc="A function that accepts an optional TypedDict parameter")
def func_takes_optional_typeddict_param(
product: Annotated[ProductOutputDict | None, "The product information"] = None,
) -> str:
if product is None:
return "No product provided"
return f"{product['product_name']} for price {product['price']}"
class ProductOutputDictWithOptional(TypedDict):
"""A product with its details."""
product_name: str
price: int
stock_quantity: int
description: str | None
@tool(desc="A function that returns a TypedDict with an optional field")
def func_returns_typeddict_with_optional_field() -> Annotated[
ProductOutputDictWithOptional, "The product, price, and quantity"
]:
"""
Returns a ProductOutput TypedDict with sample data.
"""
return ProductOutputDictWithOptional(
product_name="Product 1",
price=100,
stock_quantity=1000,
)
class ProductListDict(TypedDict):
"""A collection of products."""
category: str
products: list[str]
@tool(desc="A function that accepts a TypedDict with list fields")
def func_takes_typeddict_with_list_field(
product_list: Annotated[ProductListDict | None, "The product collection"] = None,
) -> Annotated[list[str], "The product names."]:
"""Accepts a product list with category information."""
if product_list is None:
return ["Laptop", "Phone"]
return product_list["products"]
### TypedDict with total=False for optional fields
class OptionalFieldsDict(TypedDict, total=False):
"""A TypedDict with all optional fields."""
name: str
description: str
price: int
@tool(desc="A function that returns a TypedDict with optional fields")
def func_returns_typeddict_optional_fields() -> Annotated[
OptionalFieldsDict, "Product info with optional fields"
]:
"""Returns a TypedDict where some fields may be missing."""
return OptionalFieldsDict(name="Product 1")
### Nested TypedDict example
class AddressDict(TypedDict):
"""Address information."""
street: str
city: str
zip_code: str
class CustomerDict(TypedDict):
"""Customer information with nested address."""
name: str
email: str
address: AddressDict
@tool(desc="A function that returns nested Typedicts")
def func_returns_nested_typedicts() -> Annotated[CustomerDict, "Customer information with address"]:
"""Returns a nested TypedDict structure."""
return CustomerDict(
name="John Doe",
email="john@example.com",
address=AddressDict(
street="123 Main St",
city="Anytown",
zip_code="12345",
),
)
@pytest.mark.parametrize(
"func_under_test, expected_tool_def_fields",
[
pytest.param(
func_returns_typeddict,
{
"output": ToolOutput(
value_schema=ValueSchema(
val_type="json",
enum=None,
properties={
"product_name": ValueSchema(val_type="string", enum=None),
"price": ValueSchema(val_type="integer", enum=None),
"stock_quantity": ValueSchema(val_type="integer", enum=None),
},
),
available_modes=["value", "error"],
description="The product, price, and quantity",
)
},
id="func_returns_typeddict",
),
pytest.param(
func_returns_list_of_typeddict,
{
"output": ToolOutput(
value_schema=ValueSchema(
val_type="array",
inner_val_type="json",
enum=None,
inner_properties={
"product_name": ValueSchema(val_type="string", enum=None),
"price": ValueSchema(val_type="integer", enum=None),
"stock_quantity": ValueSchema(val_type="integer", enum=None),
},
),
available_modes=["value", "error"],
description="The product, price, and quantity",
)
},
id="func_returns_list_of_typeddict",
),
pytest.param(
func_takes_optional_typeddict_param,
{
"input": ToolInput(
parameters=[
InputParameter(
name="product",
description="The product information",
required=False,
inferrable=True,
value_schema=ValueSchema(
val_type="json",
enum=None,
properties={
"product_name": ValueSchema(val_type="string", enum=None),
"price": ValueSchema(val_type="integer", enum=None),
"stock_quantity": ValueSchema(val_type="integer", enum=None),
},
),
)
]
)
},
id="func_takes_optional_typeddict_param",
),
pytest.param(
func_returns_typeddict_with_optional_field,
{
"output": ToolOutput(
value_schema=ValueSchema(
val_type="json",
enum=None,
properties={
"product_name": ValueSchema(val_type="string", enum=None),
"price": ValueSchema(val_type="integer", enum=None),
"stock_quantity": ValueSchema(val_type="integer", enum=None),
"description": ValueSchema(val_type="string", enum=None, nullable=True),
},
),
available_modes=["value", "error"],
description="The product, price, and quantity",
)
},
id="func_returns_typeddict_with_optional_field",
),
pytest.param(
func_takes_typeddict_with_list_field,
{
"input": ToolInput(
parameters=[
InputParameter(
name="product_list",
description="The product collection",
required=False,
inferrable=True,
value_schema=ValueSchema(
val_type="json",
enum=None,
properties={
"category": ValueSchema(val_type="string", enum=None),
"products": ValueSchema(
val_type="array", inner_val_type="string", enum=None
),
},
),
)
]
),
"output": ToolOutput(
value_schema=ValueSchema(
val_type="array",
inner_val_type="string",
enum=None,
),
available_modes=["value", "error"],
description="The product names.",
),
},
id="func_takes_typeddict_with_list_field",
),
pytest.param(
func_returns_typeddict_optional_fields,
{
"output": ToolOutput(
value_schema=ValueSchema(
val_type="json",
enum=None,
properties={
"name": ValueSchema(val_type="string", enum=None),
"description": ValueSchema(val_type="string", enum=None),
"price": ValueSchema(val_type="integer", enum=None),
},
),
available_modes=["value", "error"],
description="Product info with optional fields",
)
},
id="func_returns_typeddict_optional_fields",
),
pytest.param(
func_returns_nested_typedicts,
{
"output": ToolOutput(
value_schema=ValueSchema(
val_type="json",
enum=None,
properties={
"name": ValueSchema(val_type="string", enum=None),
"email": ValueSchema(val_type="string", enum=None),
"address": ValueSchema(
val_type="json",
enum=None,
properties={
"street": ValueSchema(val_type="string", enum=None),
"city": ValueSchema(val_type="string", enum=None),
"zip_code": ValueSchema(val_type="string", enum=None),
},
),
},
),
available_modes=["value", "error"],
description="Customer information with address",
)
},
id="func_returns_nested_typedicts",
),
],
)
def test_create_tool_def_from_typeddict(func_under_test, expected_tool_def_fields):
tool_def = ToolCatalog.create_tool_definition(func_under_test, "1.0")
for field, expected_value in expected_tool_def_fields.items():
assert getattr(tool_def, field) == expected_value

View file

@ -0,0 +1,73 @@
from typing import Annotated
import pytest
from arcade_core.catalog import ToolCatalog
from arcade_core.errors import ToolDefinitionError
from arcade_tdk import tool
from typing_extensions import NotRequired, TypedDict
class ProductWithNotRequired(TypedDict):
"""Product with optional field using NotRequired."""
name: str
price: float
description: NotRequired[str] # NotRequired in TypedDict field is not supported
@tool
def func_takes_typeddict_with_notrequired(
product: Annotated[ProductWithNotRequired, "Product information"],
) -> Annotated[str, "Product summary"]:
"""Process a product with NotRequired field."""
return f"Product: {product['name']}"
class ProductWithUnionField(TypedDict):
"""Product with union type field."""
name: str
price: float | int # Union type in TypedDict field is not supported
stock: int
@tool
def func_takes_typeddict_with_union_field(
product: Annotated[ProductWithUnionField, "Product with union price field"],
) -> Annotated[str, "Product info"]:
"""Process a product with union type field."""
return f"Product: {product['name']}, Price: {product['price']}"
@tool
def func_takes_optional_typeddict_non_strict(
config: ProductWithNotRequired | None = None,
) -> Annotated[str, "Configuration status"]:
"""Process optional TypedDict with non-strict syntax."""
return "processed" if config else "no config"
@pytest.mark.parametrize(
"func_under_test, exception_type",
[
pytest.param(
func_takes_typeddict_with_notrequired,
ToolDefinitionError,
id="typeddict_with_notrequired",
),
pytest.param(
func_takes_typeddict_with_union_field,
ToolDefinitionError,
id="typeddict_with_union_field",
),
pytest.param(
func_takes_optional_typeddict_non_strict,
ToolDefinitionError,
id="optional_typeddict_non_strict",
),
],
)
def test_typeddict_errors_raise_tool_definition_error(func_under_test, exception_type):
"""Test that various TypedDict error scenarios raise ToolDefinitionError."""
with pytest.raises(exception_type):
ToolCatalog.create_tool_definition(func_under_test, "1.0")

View file

@ -1,3 +1,19 @@
from arcade_google_news.tools import search_news_stories from arcade_google_news.tools import search_news_stories
from arcade_google_news.types import (
CountryCode,
GoogleNewsResponse,
LanguageCode,
SearchNewsOutput,
SearchNewsParams,
SimplifiedNewsResult,
)
__all__ = ["search_news_stories"] __all__ = [
"search_news_stories",
"CountryCode",
"GoogleNewsResponse",
"LanguageCode",
"SearchNewsOutput",
"SearchNewsParams",
"SimplifiedNewsResult",
]

View file

@ -1,4 +1,4 @@
COUNTRY_CODES = { COUNTRY_CODES: dict[str, str] = {
"af": "Afghanistan", "af": "Afghanistan",
"al": "Albania", "al": "Albania",
"dz": "Algeria", "dz": "Algeria",
@ -246,7 +246,7 @@ COUNTRY_CODES = {
} }
LANGUAGE_CODES = { LANGUAGE_CODES: dict[str, str] = {
"ar": "Arabic", "ar": "Arabic",
"bn": "Bengali", "bn": "Bengali",
"da": "Danish", "da": "Danish",

View file

@ -1,12 +1,20 @@
from typing import Annotated, Any from typing import Annotated
from arcade_tdk import ToolContext, tool from arcade_tdk import ToolContext, tool
from arcade_tdk.errors import ToolExecutionError from arcade_tdk.errors import ToolExecutionError
from arcade_google_news.constants import DEFAULT_GOOGLE_NEWS_COUNTRY, DEFAULT_GOOGLE_NEWS_LANGUAGE from arcade_google_news.constants import (
DEFAULT_GOOGLE_NEWS_COUNTRY,
DEFAULT_GOOGLE_NEWS_LANGUAGE,
)
from arcade_google_news.exceptions import CountryNotFoundError, LanguageNotFoundError from arcade_google_news.exceptions import CountryNotFoundError, LanguageNotFoundError
from arcade_google_news.google_data import COUNTRY_CODES, LANGUAGE_CODES from arcade_google_news.google_data import COUNTRY_CODES, LANGUAGE_CODES
from arcade_google_news.utils import call_serpapi, extract_news_results, prepare_params from arcade_google_news.types import CountryCode, LanguageCode, SearchNewsOutput
from arcade_google_news.utils import (
call_serpapi,
extract_news_results,
prepare_params,
)
@tool(requires_secrets=["SERP_API_KEY"]) @tool(requires_secrets=["SERP_API_KEY"])
@ -17,12 +25,13 @@ async def search_news_stories(
"Keywords to search for news articles. E.g. 'Apple launches new iPhone'.", "Keywords to search for news articles. E.g. 'Apple launches new iPhone'.",
], ],
country_code: Annotated[ country_code: Annotated[
str | None, CountryCode | None,
"2-character country code to search for news articles. E.g. 'us' (United States). " "2-character country code to search for news articles. "
"E.g. 'us' (United States). "
f"Defaults to '{DEFAULT_GOOGLE_NEWS_COUNTRY}'.", f"Defaults to '{DEFAULT_GOOGLE_NEWS_COUNTRY}'.",
] = None, ] = None,
language_code: Annotated[ language_code: Annotated[
str, LanguageCode,
"2-character language code to search for news articles. E.g. 'en' (English). " "2-character language code to search for news articles. E.g. 'en' (English). "
f"Defaults to '{DEFAULT_GOOGLE_NEWS_LANGUAGE}'.", f"Defaults to '{DEFAULT_GOOGLE_NEWS_LANGUAGE}'.",
] = DEFAULT_GOOGLE_NEWS_LANGUAGE, ] = DEFAULT_GOOGLE_NEWS_LANGUAGE,
@ -31,7 +40,7 @@ async def search_news_stories(
"Maximum number of news articles to return. Defaults to None " "Maximum number of news articles to return. Defaults to None "
"(returns all results found by the API).", "(returns all results found by the API).",
] = None, ] = None,
) -> Annotated[dict[str, list[dict[str, Any]]], "News results."]: ) -> Annotated[SearchNewsOutput, "News search results with article details."]:
"""Search for news articles related to a given query.""" """Search for news articles related to a given query."""
if not keywords: if not keywords:
raise ToolExecutionError("Keywords are required to search for news articles.") raise ToolExecutionError("Keywords are required to search for news articles.")
@ -44,4 +53,4 @@ async def search_news_stories(
params = prepare_params("google_news", q=keywords, gl=country_code, hl=language_code) params = prepare_params("google_news", q=keywords, gl=country_code, hl=language_code)
results = call_serpapi(context, params) results = call_serpapi(context, params)
return {"news_results": extract_news_results(results, limit=limit)} return SearchNewsOutput(news_results=extract_news_results(results, limit=limit))

View file

@ -0,0 +1,196 @@
"""Type definitions for Google News API responses and parameters."""
from typing_extensions import TypedDict
# For now, we'll use str type alias to maintain compatibility
# In the future, these could be converted to proper Literal types
CountryCode = str
LanguageCode = str
class SearchNewsParams(TypedDict):
"""Input parameters for searching news articles."""
keywords: str
"""Search query terms to find relevant news articles \
(e.g., 'Apple launches new iPhone')."""
country_code: CountryCode | None
"""Optional 2-letter country code to filter news by region \
(e.g., 'us' for United States, 'uk' for United Kingdom)."""
language_code: LanguageCode | None
"""Optional 2-letter language code to filter news by language \
(e.g., 'en' for English, 'es' for Spanish)."""
limit: int | None
"""Optional maximum number of news articles to return. \
If not specified, returns all results from the API."""
class SourceInfo(TypedDict, total=False):
"""Information about the news source/publication."""
name: str
"""Name of the publication (e.g., 'CNN', 'BBC News', 'The New York Times')."""
icon: str
"""URL to the source's favicon or logo image."""
authors: list[str]
"""List of author names for the article, if available."""
class NewsResult(TypedDict, total=False):
"""Individual news article from the Google News API response."""
position: int
"""Ranking position of this result in the search results."""
title: str
"""Headline or title of the news article."""
link: str
"""Full URL to the original news article."""
source: SourceInfo
"""Information about the publication source."""
date: str
"""Publication date and time (e.g., '2 hours ago', 'Dec 15, 2023')."""
snippet: str
"""Brief excerpt or summary from the article content."""
thumbnail: str
"""URL to a high-resolution thumbnail image for the article."""
thumbnail_small: str
"""URL to a low-resolution thumbnail image for the article."""
story_token: str
"""Token for accessing full coverage of this news story across multiple sources."""
stories: list["NewsResult"]
"""Related news stories from other sources covering the same topic."""
highlight: dict
"""Additional highlighted information about the story."""
class SearchMetadata(TypedDict, total=False):
"""Metadata about the search request and processing."""
id: str
"""Unique identifier for this search request within SerpApi."""
status: str
"""Current processing status ('Processing', 'Success', or 'Error')."""
json_endpoint: str
"""URL to retrieve the JSON results for this search."""
created_at: str
"""Timestamp when the search request was created."""
processed_at: str
"""Timestamp when the search request was processed."""
google_news_url: str
"""Original Google News URL that would return these results."""
total_time_taken: float
"""Total time in seconds taken to process this search."""
class SearchParameters(TypedDict, total=False):
"""Parameters used for the search request."""
engine: str
"""Search engine used (always 'google_news' for this API)."""
q: str
"""Search query string."""
gl: str
"""Country code used for geographic filtering."""
hl: str
"""Language code used for language filtering."""
topic_token: str
"""Token for accessing specific news topics (e.g., 'World', 'Business', 'Technology')."""
publication_token: str
"""Token for accessing news from specific publishers."""
class MenuLink(TypedDict):
"""Navigation link for news categories or topics."""
title: str
"""Display text for the menu item (e.g., 'Technology', 'Sports', 'Business')."""
topic_token: str
"""Token to access this specific topic or category."""
serpapi_link: str
"""SerpApi URL to search within this topic."""
class TopStoriesLink(TypedDict):
"""Link to top stories section."""
topic_token: str
"""Token to access top stories."""
serpapi_link: str
"""SerpApi URL to retrieve top stories."""
class GoogleNewsResponse(TypedDict, total=False):
"""Complete response from the Google News API."""
search_metadata: SearchMetadata
"""Metadata about the search request and processing."""
search_parameters: SearchParameters
"""Parameters that were used for this search."""
news_results: list[NewsResult]
"""List of news articles matching the search criteria."""
menu_links: list[MenuLink]
"""Navigation links to different news categories and topics."""
top_stories_link: TopStoriesLink
"""Link to access top stories."""
title: str
"""Title of the page or topic being displayed."""
class SimplifiedNewsResult(TypedDict):
"""Simplified news article format for tool output."""
title: str
"""Headline of the news article."""
link: str
"""URL to the full article."""
source: str | None
"""Name of the publication source."""
date: str | None
"""When the article was published."""
snippet: str | None
"""Brief excerpt from the article."""
class SearchNewsOutput(TypedDict):
"""Output format for the search_news_stories tool."""
news_results: list[SimplifiedNewsResult]
"""List of news articles in simplified format."""

View file

@ -5,6 +5,8 @@ from arcade_tdk import ToolContext
from arcade_tdk.errors import ToolExecutionError from arcade_tdk.errors import ToolExecutionError
from serpapi import Client as SerpClient from serpapi import Client as SerpClient
from arcade_google_news.types import GoogleNewsResponse, SimplifiedNewsResult
def prepare_params(engine: str, **kwargs: Any) -> dict[str, Any]: def prepare_params(engine: str, **kwargs: Any) -> dict[str, Any]:
""" """
@ -23,7 +25,7 @@ def prepare_params(engine: str, **kwargs: Any) -> dict[str, Any]:
return params return params
def call_serpapi(context: ToolContext, params: dict) -> dict: def call_serpapi(context: ToolContext, params: dict[str, Any]) -> GoogleNewsResponse:
""" """
Execute a search query using the SerpAPI client and return the results as a dictionary. Execute a search query using the SerpAPI client and return the results as a dictionary.
@ -38,7 +40,7 @@ def call_serpapi(context: ToolContext, params: dict) -> dict:
client = SerpClient(api_key=api_key) client = SerpClient(api_key=api_key)
try: try:
search = client.search(params) search = client.search(params)
return cast(dict[str, Any], search.as_dict()) return cast(GoogleNewsResponse, search.as_dict())
except Exception as e: except Exception as e:
# SerpAPI error messages sometimes contain the API key, so we need to sanitize it # SerpAPI error messages sometimes contain the API key, so we need to sanitize it
sanitized_e = re.sub(r"(api_key=)[^ &]+", r"\1***", str(e)) sanitized_e = re.sub(r"(api_key=)[^ &]+", r"\1***", str(e))
@ -48,16 +50,20 @@ def call_serpapi(context: ToolContext, params: dict) -> dict:
) )
def extract_news_results(results: dict[str, Any], limit: int | None = None) -> list[dict[str, Any]]: def extract_news_results(
news_results = [] results: GoogleNewsResponse, limit: int | None = None
) -> list[SimplifiedNewsResult]:
news_results: list[SimplifiedNewsResult] = []
for result in results.get("news_results", []): for result in results.get("news_results", []):
news_results.append({ news_results.append(
"title": result.get("title"), SimplifiedNewsResult(
"snippet": result.get("snippet"), title=result.get("title", ""),
"link": result.get("link"), link=result.get("link", ""),
"date": result.get("date"), source=result.get("source", {}).get("name"),
"source": result.get("source", {}).get("name"), date=result.get("date"),
}) snippet=result.get("snippet"),
)
)
if limit: if limit:
return news_results[:limit] return news_results[:limit]