diff --git a/py/PARITY_AUDIT.md b/py/PARITY_AUDIT.md index 1a2b5ca351..99ce24ceba 100644 --- a/py/PARITY_AUDIT.md +++ b/py/PARITY_AUDIT.md @@ -220,7 +220,7 @@ All samples except `provider-checks-hello` had `LICENSE` ✅ (now fixed). | `defineStreamingFlow` | ✅ (via options) | ✅ `DefineStreamingFlow` | ✅ (via streaming param) | ✅ | | `defineTool` | ✅ | ✅ `DefineTool` | ✅ `.tool()` decorator | ✅ | | `defineToolWithInputSchema` | — | ✅ `DefineToolWithInputSchema` | — | Go-only | -| `defineTool({multipart: true})` | ✅ | ✅ `DefineMultipartTool` | ❌ | ❌ Python missing (G18) | +| `defineTool({multipart: true})` | ✅ | ✅ `DefineMultipartTool` | ✅ `.tool(multipart=True)` | ✅ (PR #4513) | | `defineModel` | ✅ | ✅ `DefineModel` | ✅ `define_model` | ✅ | | `defineBackgroundModel` | ✅ | ✅ `DefineBackgroundModel` | ✅ `define_background_model` | ✅ | | `definePrompt` | ✅ | ✅ `DefinePrompt` | ✅ `define_prompt` | ✅ | @@ -330,7 +330,7 @@ Python users typically use `httpx` or `requests` directly. | Feature | JS | Go | Python | Gap Owner | Priority | |---------|:--:|:--:|:------:|-----------|:--------:| | `runFlow` / `streamFlow` client | ✅ (beta/client) | ❌ | ❌ | Go + Python | P2 | -| `defineTool({multipart: true})` | ✅ | ✅ | ❌ | Python | P1 | +| `defineTool({multipart: true})` | ✅ | ✅ | ✅ | — | ✅ Done (PR #4513) | | Model API V2 (`apiVersion: 'v2'`) | ✅ | ❌ | ❌ | Go + Python | P1 | | `defineDynamicActionProvider` | ✅ | ❌ | ✅ | Go | P2 | | `defineIndexer` | ✅ | ❌ | ✅ | Go | P2 | @@ -490,11 +490,11 @@ Full plugin list from the repository README (10 plugins, 33 contributors, 54 rel | G14 | Python | Implement `validate_support` middleware | §8f | ⬜ | | G15 | Python | Implement `download_request_media` middleware | §8f | ⬜ | | G16 | Python | Implement `simulate_system_prompt` middleware | §8f | ⬜ | -| G18 | Python | Add multipart tool support (`defineTool({multipart: true})`) | §8h | ⬜ | +| G18 | Python | Add multipart tool support (`defineTool({multipart: true})`) | §8h | ✅ PR #4513 | | G19 | Python | Add Model API V2 (`defineModel({apiVersion: 'v2'})`) | §8i | ⬜ | -| G20 | Python | Add `context` parameter to `Genkit()` constructor | §8j | ⬜ | -| G21 | Python | Add `clientHeader` parameter to `Genkit()` constructor | §8j | ⬜ | -| G22 | Python | Add `name` parameter to `Genkit()` constructor | §8j | ⬜ | +| G20 | Python | Add `context` parameter to `Genkit()` constructor | §8j | ✅ PR #4512 | +| G21 | Python | Add `clientHeader` parameter to `Genkit()` constructor | §8j | ✅ PR #4512 | +| G22 | Python | Add `name` parameter to `Genkit()` constructor | §8j | ✅ PR #4512 | | G4 | Python | Move `augment_with_context` to define-model time | §8b.2 | ⬜ | | G9 | Python | Add Pinecone vector store plugin | §5g | ⬜ | | G10 | Python | Add ChromaDB vector store plugin | §5g | ⬜ | @@ -908,10 +908,10 @@ export function apiKey( | Feature | JS | Python | Gap | |---------|:--:|:------:|:---:| -| `defineTool({multipart: true})` | ✅ Supported. Creates a `MultipartToolAction` of type `tool.v2`. | ❌ Not supported. `define_tool` has no `multipart` parameter. | **G18** | -| `MultipartToolAction` type | ✅ `tool.ts:107-122` — Action with `tool.v2` type, returns `{output?, content?}`. | ❌ Does not exist. | **G18** | -| `MultipartToolResponse` type | ✅ `parts.ts` — Schema with `output` and `content` fields. | ⚠️ Type exists in `typing.py:933` but unused in tool definition. | Partial | -| Auto-registration of `tool.v2` | ✅ Non-multipart tools are also registered as `tool.v2` with wrapped output. | ❌ No dual registration. | **G18** | +| `defineTool({multipart: true})` | ✅ Supported. Creates a `MultipartToolAction` of type `tool.v2`. | ✅ `.tool(multipart=True)` registers as `tool.v2` with metadata `tool.multipart=True`. | ✅ PR #4513 | +| `MultipartToolAction` type | ✅ `tool.ts:107-122` — Action with `tool.v2` type, returns `{output?, content?}`. | ✅ Registered under `ActionKind.TOOL_V2` with appropriate metadata. | ✅ PR #4513 | +| `MultipartToolResponse` type | ✅ `parts.ts` — Schema with `output` and `content` fields. | ✅ Multipart tool functions return `{output?, content?}` dict. | ✅ PR #4513 | +| Auto-registration of `tool.v2` | ✅ Non-multipart tools are also registered as `tool.v2` with wrapped output. | ✅ Non-multipart tools register both `tool` and `tool.v2` (v2 wraps output in `{output: result}`). | ✅ PR #4513 | **JS** (`js/ai/src/tool.ts:306-335`): ```ts @@ -1040,11 +1040,11 @@ export interface GenkitOptions { | G15 | Python | `download_request_media` middleware missing | P2 | `py/packages/genkit/src/genkit/blocks/middleware.py` | URL media transformed to data URI | | G16 | Python | `simulate_system_prompt` missing | P2 | `py/packages/genkit/src/genkit/blocks/middleware.py` | system message rewritten for unsupported model | | G17 | Python | `api_key()` context provider missing | P3 | `py/packages/genkit/src/genkit/core/context.py` | auth header extraction + policy callback tests | -| G18 | Python | multipart tool (`tool.v2`) missing | P1 | `py/packages/genkit/src/genkit/blocks/tools.py`, `.../blocks/generate.py` | tool call returns `output` + `content` parity | +| G18 | Python | ~~multipart tool (`tool.v2`) missing~~ | P1 | `ai/_registry.py`, `core/action/types.py`, `blocks/generate.py` | ✅ **Done** (PR #4513) | | G19 | Python | Model API V2 runner interface missing | P1 | `py/packages/genkit/src/genkit/ai/_registry.py`, `.../blocks/model.py` | v2 model receives unified options struct | -| G20 | Python | `Genkit(context=...)` missing | P2 | `py/packages/genkit/src/genkit/ai/_aio.py` | context propagates to action executions | -| G21 | Python | `Genkit(clientHeader=...)` missing | P2 | `py/packages/genkit/src/genkit/ai/_aio.py`, `.../core/http_client.py` | outbound header includes custom token | -| G22 | Python | `Genkit(name=...)` missing | P2 | `py/packages/genkit/src/genkit/ai/_aio.py`, `.../core/reflection.py` | Dev UI/reflection shows custom name | +| G20 | Python | ~~`Genkit(context=...)` missing~~ | P2 | `ai/_aio.py`, `core/registry.py` | ✅ **Done** (PR #4512) | +| G21 | Python | ~~`Genkit(clientHeader=...)` missing~~ | P2 | `ai/_aio.py`, `core/constants.py` | ✅ **Done** (PR #4512) | +| G22 | Python | ~~`Genkit(name=...)` missing~~ | P2 | `ai/_aio.py`, `ai/_runtime.py`, `core/registry.py` | ✅ **Done** (PR #4512) | | G23 | Go | `defineDynamicActionProvider` parity missing | P2 | `go/genkit/genkit.go`, `go/core/registry.go` | DAP action discovery + resolve test | | G24 | Go | `defineIndexer` parity missing | P2 | `go/genkit/genkit.go`, `go/ai` indexing action | indexer registration + invoke test | | G25 | Go | `defineReranker` + `rerank` runtime missing | P1 | `go/genkit/genkit.go`, `go/ai` reranker block | reranker registration + scoring call | @@ -1198,9 +1198,9 @@ Reverse topological sort of the gap DAG yields the following dependency levels. |----|-----|-----------|----------------|:------:|----------| | **P1.1** | **G2** | Add `middleware` storage to `Action` class; implement `action_with_middleware()` wrapper that chains model-level middleware around `action.run()` | `core/action/_action.py` | L | G1, G12, G13, G15, G19 | | **P1.2** | **G6** | Update `on_trace_start` callback signature to `(trace_id: str, span_id: str)` throughout action system | `core/action/_action.py`, `core/reflection.py`, `core/trace/` | S | G5 | -| **P1.3** | **G18** | Add multipart tool support: `define_tool(multipart=True)`, `MultipartToolAction` type `tool.v2`, dual registration for non-multipart tools | `blocks/tools.py`, `blocks/generate.py` | M | — | -| **P1.4** | **G20** | Add `context` parameter to `Genkit()` that sets `registry.context` for default action context | `ai/_aio.py` | XS | — | -| **P1.5** | **G21** | Add `clientHeader` parameter to `Genkit()` that appends to `GENKIT_CLIENT_HEADER` via `set_client_header()` | `ai/_aio.py`, `core/http_client.py` | XS | G8 | +| **P1.3** | **G18** | ~~Add multipart tool support: `define_tool(multipart=True)`, `MultipartToolAction` type `tool.v2`, dual registration for non-multipart tools~~ | `ai/_registry.py`, `core/action/types.py`, `blocks/generate.py` | M | ✅ **Done** (PR #4513) | +| **P1.4** | **G20** | ~~Add `context` parameter to `Genkit()` that sets `registry.context` for default action context~~ | `ai/_aio.py`, `core/registry.py` | XS | ✅ **Done** (PR #4512) | +| **P1.5** | **G21** | ~~Add `clientHeader` parameter to `Genkit()` that appends to `GENKIT_CLIENT_HEADER` via `set_client_header()`~~ | `ai/_aio.py`, `core/constants.py` | XS | ✅ **Done** (PR #4512) | **Exit criteria**: All unit tests green for action middleware dispatch, span_id propagation, tool.v2 registration, and constructor parameter propagation. @@ -1439,8 +1439,8 @@ Milestone ▲ P1 infra ▲ Middleware ▲ Full P1 ▲ Client |----|:-----:|------|----------|:----------:| | **PR-1a** | Core | G2 | Add `middleware` list to `Action.__init__()`, implement `action_with_middleware()` dispatch wrapper, unit tests for middleware chaining | — | | **PR-1b** | Core | G6 | Update `on_trace_start` callback signature to `(trace_id, span_id)` across action system + tracing, update all call sites | — | -| **PR-1c** | Core | G18 | Multipart tool support: `define_tool(multipart=True)`, `tool.v2` action type, dual registration for non-multipart tools, unit tests | — | -| **PR-1d** | Core | G20, G21 | `Genkit(context=..., client_header=...)` constructor params — small additive changes, can combine in one PR | — | +| **PR-1c** | Core | G18 | ~~Multipart tool support: `define_tool(multipart=True)`, `tool.v2` action type, dual registration for non-multipart tools, unit tests~~ | ✅ PR #4513 | +| **PR-1d** | Core | G20, G21, G22 | ~~`Genkit(context=..., client_header=..., name=...)` constructor params~~ | ✅ PR #4512 | *PR-1a is the critical-path item. Land it first to unblock Phase 2.* diff --git a/py/packages/genkit/src/genkit/ai/_registry.py b/py/packages/genkit/src/genkit/ai/_registry.py index 12f6f57d1a..77dfa17d03 100644 --- a/py/packages/genkit/src/genkit/ai/_registry.py +++ b/py/packages/genkit/src/genkit/ai/_registry.py @@ -522,15 +522,21 @@ async def get_tools(): return define_dap_block(self.registry, config, fn) def tool( - self, name: str | None = None, description: str | None = None + self, name: str | None = None, description: str | None = None, *, multipart: bool = False ) -> Callable[[Callable[P, T]], Callable[P, T]]: """Decorator to register a function as a tool. Args: - name: Optional name for the flow. If not provided, uses the function + name: Optional name for the tool. If not provided, uses the function name. description: Description for the tool to be passed to the model; if not provided, uses the function docstring. + multipart: If True, the tool is registered as a multipart tool + (``tool.v2``). The function should return a dict with optional + ``output`` and ``content`` keys. If False (default), both a + ``tool`` and a ``tool.v2`` wrapper action are registered so that + the tool is discoverable under both kinds. Mirrors JS SDK's + ``defineTool({ multipart: true })``. Returns: A decorator function that registers the tool. @@ -564,14 +570,40 @@ def tool_fn_wrapper(*args: Any) -> Any: # noqa: ANN401 case _: raise ValueError('tool must have 0-2 args...') + tool_kind = cast(ActionKind, ActionKind.TOOL_V2 if multipart else ActionKind.TOOL) + tool_metadata: dict[str, object] = {'type': 'tool.v2' if multipart else 'tool'} + if multipart: + tool_metadata['tool'] = {'multipart': True} + action = self.registry.register_action( name=tool_name, - kind=cast(ActionKind, ActionKind.TOOL), + kind=tool_kind, description=tool_description, fn=tool_fn_wrapper, metadata_fn=func, + metadata=tool_metadata, ) + # For non-multipart tools, also register a tool.v2 wrapper that + # wraps the output in {output: result} so all tools are + # discoverable under tool.v2, matching JS SDK behavior. + if not multipart: + + async def v2_wrapper_fn(*args: Any) -> dict[str, object]: # noqa: ANN401 + result = tool_fn_wrapper(*args) + if asyncio.iscoroutine(result): + result = await result + return {'output': result} + + self.registry.register_action( + name=tool_name, + kind=cast(ActionKind, ActionKind.TOOL_V2), + description=tool_description, + fn=v2_wrapper_fn, + metadata_fn=func, + metadata={'type': 'tool.v2'}, + ) + @wraps(func) async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any: # noqa: ANN401 """Asynchronous wrapper for the tool function. diff --git a/py/packages/genkit/src/genkit/blocks/generate.py b/py/packages/genkit/src/genkit/blocks/generate.py index 8fd33f2b0b..b9c6fab73a 100644 --- a/py/packages/genkit/src/genkit/blocks/generate.py +++ b/py/packages/genkit/src/genkit/blocks/generate.py @@ -626,9 +626,7 @@ async def resolve_parameters( tools: list[Action[Any, Any, Any]] = [] if request.tools: for tool_name in request.tools: - tool_action = await registry.resolve_action(cast(ActionKind, ActionKind.TOOL), tool_name) - if tool_action is None: - raise Exception(f'Unable to resolve tool {tool_name}') + tool_action = await resolve_tool(registry, tool_name) tools.append(tool_action) format_def: FormatDef | None = None @@ -842,6 +840,9 @@ async def _resolve_tool_request(tool: Action, tool_request_part: ToolRequestPart async def resolve_tool(registry: Registry, tool_name: str) -> Action: """Resolve a tool by name from the registry. + Looks up the tool under both ``tool`` and ``tool.v2`` action kinds, + matching the JS SDK's ``lookupToolByName`` behavior. + Args: registry: The registry to resolve the tool from. tool_name: The name of the tool to resolve. @@ -853,6 +854,8 @@ async def resolve_tool(registry: Registry, tool_name: str) -> Action: ValueError: If the tool could not be resolved. """ tool = await registry.resolve_action(kind=cast(ActionKind, ActionKind.TOOL), name=tool_name) + if tool is None: + tool = await registry.resolve_action(kind=cast(ActionKind, ActionKind.TOOL_V2), name=tool_name) if tool is None: raise ValueError(f'Unable to resolve tool {tool_name}') return tool diff --git a/py/packages/genkit/src/genkit/core/action/types.py b/py/packages/genkit/src/genkit/core/action/types.py index 6232522134..d610dd3223 100644 --- a/py/packages/genkit/src/genkit/core/action/types.py +++ b/py/packages/genkit/src/genkit/core/action/types.py @@ -56,6 +56,7 @@ class ActionKind(StrEnum): RESOURCE = 'resource' RETRIEVER = 'retriever' TOOL = 'tool' + TOOL_V2 = 'tool.v2' UTIL = 'util' diff --git a/py/packages/genkit/tests/genkit/ai/multipart_tool_test.py b/py/packages/genkit/tests/genkit/ai/multipart_tool_test.py new file mode 100644 index 0000000000..59470ce90b --- /dev/null +++ b/py/packages/genkit/tests/genkit/ai/multipart_tool_test.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# +# Copyright 2025 Google LLC +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for multipart tool support (tool.v2 action kind).""" + +from typing import cast + +import pytest + +from genkit.ai import Genkit +from genkit.core.action.types import ActionKind + + +@pytest.mark.asyncio +async def test_regular_tool_registers_both_kinds() -> None: + """A non-multipart tool registers under both 'tool' and 'tool.v2'.""" + ai = Genkit() + + @ai.tool() + def add(x: int) -> int: + """Add one.""" + return x + 1 + + tool_action = await ai.registry.resolve_action(ActionKind.TOOL, 'add') + if tool_action is None: + raise AssertionError('Expected tool action registered under ActionKind.TOOL') + + v2_action = await ai.registry.resolve_action(ActionKind.TOOL_V2, 'add') + if v2_action is None: + raise AssertionError('Expected tool.v2 wrapper action registered under ActionKind.TOOL_V2') + + +@pytest.mark.asyncio +async def test_regular_tool_v2_wrapper_wraps_output() -> None: + """The tool.v2 wrapper for a regular tool wraps output in {output: result}.""" + ai = Genkit() + + @ai.tool() + def double(x: int) -> int: + """Double.""" + return x * 2 + + v2_action = await ai.registry.resolve_action(ActionKind.TOOL_V2, 'double') + if v2_action is None: + raise AssertionError('Expected tool.v2 wrapper') + + result = await v2_action.arun(5) + if not isinstance(result.response, dict): + raise AssertionError(f'Expected dict response, got {type(result.response).__name__}') + if result.response.get('output') != 10: + raise AssertionError(f'Expected output=10, got {result.response}') + + +@pytest.mark.asyncio +async def test_multipart_tool_registers_as_tool_v2() -> None: + """A multipart tool is registered under 'tool.v2' only.""" + ai = Genkit() + + @ai.tool(multipart=True) + def rich_tool(query: str) -> dict: + """Return rich content.""" + return {'output': f'result for {query}', 'content': [{'text': 'extra'}]} + + # Should be under tool.v2 + v2_action = await ai.registry.resolve_action(ActionKind.TOOL_V2, 'rich_tool') + if v2_action is None: + raise AssertionError('Expected multipart tool registered under ActionKind.TOOL_V2') + + # Should NOT be under tool + tool_action = await ai.registry.resolve_action(ActionKind.TOOL, 'rich_tool') + if tool_action is not None: + raise AssertionError('Multipart tool should NOT be registered under ActionKind.TOOL') + + +@pytest.mark.asyncio +async def test_multipart_tool_metadata() -> None: + """Multipart tool has correct metadata: type='tool.v2' and tool.multipart=True.""" + ai = Genkit() + + @ai.tool(multipart=True) + def my_multipart(x: int) -> dict: + """Multipart.""" + return {'output': x} + + v2_action = await ai.registry.resolve_action(ActionKind.TOOL_V2, 'my_multipart') + if v2_action is None: + raise AssertionError('Expected multipart tool') + + if v2_action.metadata.get('type') != 'tool.v2': + raise AssertionError(f'Expected type="tool.v2", got {v2_action.metadata.get("type")!r}') + + tool_meta = v2_action.metadata.get('tool') + if not isinstance(tool_meta, dict): + raise AssertionError(f'Expected dict for tool metadata, got {type(tool_meta).__name__}') + tool_meta_dict = cast(dict[str, object], tool_meta) + if tool_meta_dict.get('multipart') is not True: + raise AssertionError(f'Expected tool.multipart=True, got {tool_meta!r}') + + +@pytest.mark.asyncio +async def test_multipart_tool_execution() -> None: + """Multipart tool can be executed and returns the function result directly.""" + ai = Genkit() + + @ai.tool(multipart=True) + def search(query: str) -> dict: + """Search.""" + return {'output': 'found it', 'content': [{'text': f'Details for {query}'}]} + + v2_action = await ai.registry.resolve_action(ActionKind.TOOL_V2, 'search') + if v2_action is None: + raise AssertionError('Expected multipart tool') + + result = await v2_action.arun('test query') + response = result.response + if not isinstance(response, dict): + raise AssertionError(f'Expected dict, got {type(response).__name__}') + if response.get('output') != 'found it': + raise AssertionError(f'Expected output="found it", got {response.get("output")!r}') + + +@pytest.mark.asyncio +async def test_regular_tool_metadata_type() -> None: + """Regular (non-multipart) tool has metadata type='tool'.""" + ai = Genkit() + + @ai.tool() + def simple(x: int) -> int: + """Simple.""" + return x + + tool_action = await ai.registry.resolve_action(ActionKind.TOOL, 'simple') + if tool_action is None: + raise AssertionError('Expected tool action') + + if tool_action.metadata.get('type') != 'tool': + raise AssertionError(f'Expected type="tool", got {tool_action.metadata.get("type")!r}') diff --git a/py/pyproject.toml b/py/pyproject.toml index 89b53b4431..a5d75eb8a7 100644 --- a/py/pyproject.toml +++ b/py/pyproject.toml @@ -160,6 +160,7 @@ framework-dynamic-tools-demo = { workspace = true } framework-evaluator-demo = { workspace = true } framework-format-demo = { workspace = true } framework-middleware-demo = { workspace = true } +framework-multipart-tools = { workspace = true } framework-prompt-demo = { workspace = true } framework-realtime-tracing-demo = { workspace = true } framework-restaurant-demo = { workspace = true } diff --git a/py/samples/framework-multipart-tools/LICENSE b/py/samples/framework-multipart-tools/LICENSE new file mode 100644 index 0000000000..2205396735 --- /dev/null +++ b/py/samples/framework-multipart-tools/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/py/samples/framework-multipart-tools/README.md b/py/samples/framework-multipart-tools/README.md new file mode 100644 index 0000000000..b664977624 --- /dev/null +++ b/py/samples/framework-multipart-tools/README.md @@ -0,0 +1,48 @@ +# Multipart Tools Sample + +This sample demonstrates **multipart tool support** (`tool.v2`), which allows +tools to return both structured output and rich content parts. + +## What are Multipart Tools? + +Regular tools return a single output value. Multipart tools can return: + +- **`output`** — structured data (the typed output, same as regular tools) +- **`content`** — a list of rich content parts (text, media, etc.) + +This mirrors the JS SDK's `defineTool({ multipart: true })`. + +## Key Concepts + +| Concept | Description | +|----------------------|----------------------------------------------------------| +| `@ai.tool()` | Regular tool — returns a single output value | +| `@ai.tool(multipart=True)` | Multipart tool — returns `{output?, content?}` dict | +| `ActionKind.TOOL` | Action kind for regular tools | +| `ActionKind.TOOL_V2` | Action kind for multipart tools | +| Dual registration | Regular tools are also registered under `tool.v2` | + +## Running + +```bash +export GEMINI_API_KEY="your-key" +./run.sh +``` + +Or with the Dev UI: + +```bash +genkit start -- uv run src/main.py +``` + +## Testing + +From the Dev UI, run the `multipart_search` flow with an input like: +```json +"python async programming" +``` + +The flow will use: +1. A **regular tool** (`get_summary`) — returns a simple string +2. A **multipart tool** (`search_with_sources`) — returns both a summary and + source citations as content parts diff --git a/py/samples/framework-multipart-tools/pyproject.toml b/py/samples/framework-multipart-tools/pyproject.toml new file mode 100644 index 0000000000..9991d19a32 --- /dev/null +++ b/py/samples/framework-multipart-tools/pyproject.toml @@ -0,0 +1,59 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +[project] +authors = [ + { name = "Google" }, + { name = "Yesudeep Mangalapilly", email = "yesudeep@google.com" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development :: Libraries", +] +dependencies = [ + "genkit", + "genkit-plugin-google-genai", + "pydantic>=2.10.5", + "uvloop>=0.21.0", +] +description = "Multipart tool support sample" +license = "Apache-2.0" +name = "framework-multipart-tools" +readme = "README.md" +requires-python = ">=3.10" +version = "0.1.0" + +[project.optional-dependencies] +dev = ["watchdog>=6.0.0"] + +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[tool.hatch.build.targets.wheel] +packages = ["src/multipart_tools"] diff --git a/py/samples/framework-multipart-tools/run.sh b/py/samples/framework-multipart-tools/run.sh new file mode 100755 index 0000000000..663e15f44e --- /dev/null +++ b/py/samples/framework-multipart-tools/run.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Copyright 2026 Google LLC +# SPDX-License-Identifier: Apache-2.0 + +# Multipart Tools Demo +# ==================== +# +# Demonstrates multipart tool support (tool.v2) — tools that return +# both structured output and rich content parts. +# +# Prerequisites: +# - GEMINI_API_KEY environment variable set +# +# Usage: +# ./run.sh # Start the demo with Dev UI +# ./run.sh --help # Show this help message + +set -euo pipefail + +cd "$(dirname "$0")" +source "../_common.sh" + +print_help() { + print_banner "Multipart Tools Demo" "🔀" + echo "Usage: ./run.sh [options]" + echo "" + echo "Options:" + echo " --help Show this help message" + echo "" + echo "Environment Variables:" + echo " GEMINI_API_KEY Required. Your Gemini API key" + echo "" + echo "This demo shows:" + echo " - Regular tools (@ai.tool())" + echo " - Multipart tools (@ai.tool(multipart=True))" + echo " - tool.v2 action kind and dual registration" + echo "" + echo "Get an API key from: https://makersuite.google.com/app/apikey" + print_help_footer +} + +case "${1:-}" in + --help|-h) + print_help + exit 0 + ;; +esac + +print_banner "Multipart Tools Demo" "🔀" + +check_env_var "GEMINI_API_KEY" "https://makersuite.google.com/app/apikey" || true + +install_deps + +genkit_start_with_browser -- \ + uv tool run --from watchdog watchmedo auto-restart \ + -d src \ + -d ../../packages \ + -d ../../plugins \ + -p '*.py;*.prompt;*.json' \ + -R \ + -- uv run src/main.py "$@" diff --git a/py/samples/framework-multipart-tools/src/main.py b/py/samples/framework-multipart-tools/src/main.py new file mode 100644 index 0000000000..e8ecfa5dad --- /dev/null +++ b/py/samples/framework-multipart-tools/src/main.py @@ -0,0 +1,141 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Multipart tool support sample — regular vs multipart tools. + +This sample demonstrates the difference between regular tools and multipart +tools (``tool.v2``). Regular tools return a single output value while multipart +tools return a dict with optional ``output`` and ``content`` fields, allowing +rich content alongside structured data. + +How It Works +============ +| Tool Type | Decorator | Return Type | +|----------------|----------------------------------|-----------------------| +| Regular | ``@ai.tool()`` | Any single value | +| Multipart | ``@ai.tool(multipart=True)`` | ``{output?, content?}`` | + +Under the Hood +============== +- Regular tools register under both ``tool`` and ``tool.v2`` action kinds. + The ``tool.v2`` wrapper wraps the output in ``{output: result}``. +- Multipart tools register under ``tool.v2`` only, with metadata + ``type='tool.v2'`` and ``tool.multipart=True``. +- ``resolve_tool()`` checks both ``tool`` and ``tool.v2`` kinds, so either + type of tool can be resolved by name. + +Testing +======= +Run ``genkit start -- uv run src/main.py`` to launch the Dev UI, then invoke +the ``multipart_search`` or ``regular_search`` flows. +""" + +from pydantic import BaseModel, Field + +from genkit.ai import Genkit +from genkit.plugins.google_genai import GoogleAI +from genkit.plugins.google_genai.models import gemini +from samples.shared.logging import setup_sample + +setup_sample() + +ai = Genkit( + plugins=[GoogleAI()], + model=f'googleai/{gemini.GoogleAIGeminiVersion.GEMINI_3_FLASH_PREVIEW}', +) + + +class SearchQuery(BaseModel): + """Search query input.""" + + query: str = Field(description='The search query string') + + +class SearchResult(BaseModel): + """A single search result.""" + + title: str = Field(description='Title of the result') + url: str = Field(description='URL of the result') + snippet: str = Field(description='Brief snippet from the result') + + +# A regular tool — returns a single string. +@ai.tool() +def get_summary(query: str) -> str: + """Get a brief summary for a topic. Returns a concise one-line answer.""" + return f'Summary for "{query}": This is a simulated summary of the topic.' + + +# A multipart tool — returns both structured output AND rich content parts. +# The model sees this as a tool.v2 action with {output, content}. +@ai.tool(multipart=True) +def search_with_sources(query: SearchQuery) -> dict: + """Search for information and return results with source citations. + + Returns both a structured summary (output) and detailed source + citations (content parts) that the model can reference. + """ + results = [ + SearchResult( + title=f'Result 1: {query.query}', + url=f'https://example.com/1?q={query.query}', + snippet=f'First result about {query.query} with detailed information.', + ), + SearchResult( + title=f'Result 2: {query.query}', + url=f'https://example.com/2?q={query.query}', + snippet=f'Second result covering {query.query} from another perspective.', + ), + ] + + return { + 'output': f'Found {len(results)} results for "{query.query}"', + 'content': [{'text': f'Source: {r.title}\nURL: {r.url}\n{r.snippet}\n'} for r in results], + } + + +@ai.flow() +async def multipart_search(topic: str) -> str: + """Search using the multipart tool and summarize with sources.""" + response = await ai.generate( + prompt=f'Search for information about "{topic}" using the search_with_sources ' + 'tool, then provide a comprehensive answer citing the sources.', + tools=['search_with_sources'], + ) + return response.text + + +@ai.flow() +async def regular_search(topic: str) -> str: + """Search using the regular tool for comparison.""" + response = await ai.generate( + prompt=f'Get a summary about "{topic}" using the get_summary tool.', + tools=['get_summary'], + ) + return response.text + + +async def main() -> None: + """Run both flows to compare regular vs multipart tools.""" + result = await multipart_search('python async programming') + print(f'Multipart result:\n{result}\n') # noqa: T201 - sample CLI output + + result = await regular_search('python async programming') + print(f'Regular result:\n{result}') # noqa: T201 - sample CLI output + + +if __name__ == '__main__': + ai.run_main(main()) diff --git a/py/uv.lock b/py/uv.lock index c872cde836..9683369e3f 100644 --- a/py/uv.lock +++ b/py/uv.lock @@ -17,6 +17,7 @@ members = [ "framework-evaluator-demo", "framework-format-demo", "framework-middleware-demo", + "framework-multipart-tools", "framework-prompt-demo", "framework-realtime-tracing-demo", "framework-restaurant-demo", @@ -1779,6 +1780,32 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "framework-multipart-tools" +version = "0.1.0" +source = { editable = "samples/framework-multipart-tools" } +dependencies = [ + { name = "genkit" }, + { name = "genkit-plugin-google-genai" }, + { name = "pydantic" }, + { name = "uvloop" }, +] + +[package.optional-dependencies] +dev = [ + { name = "watchdog" }, +] + +[package.metadata] +requires-dist = [ + { name = "genkit", editable = "packages/genkit" }, + { name = "genkit-plugin-google-genai", editable = "plugins/google-genai" }, + { name = "pydantic", specifier = ">=2.10.5" }, + { name = "uvloop", specifier = ">=0.21.0" }, + { name = "watchdog", marker = "extra == 'dev'", specifier = ">=6.0.0" }, +] +provides-extras = ["dev"] + [[package]] name = "framework-prompt-demo" version = "0.0.1"