From 20daff3817dfd69a3e700c2cd601d061c583e479 Mon Sep 17 00:00:00 2001 From: Yesudeep Mangalapilly Date: Fri, 6 Feb 2026 11:58:02 -0800 Subject: [PATCH] Revert "feat(py/genkit): complete DAP integration with registry (#4459)" This reverts commit c541f48d6d64b7f624b3cdd28acc126d1172dc99. --- py/GEMINI.md | 207 +------ py/engdoc/parity-analysis/roadmap.md | 120 +--- py/packages/genkit/src/genkit/blocks/dap.py | 125 ++--- .../genkit/src/genkit/core/action/__init__.py | 3 +- .../genkit/src/genkit/core/action/types.py | 16 - .../genkit/src/genkit/core/registry.py | 280 +--------- .../genkit/tests/genkit/blocks/dap_test.py | 166 +----- py/pyproject.toml | 1 - py/samples/dap-demo/README.md | 183 ------- py/samples/dap-demo/pyproject.toml | 59 -- py/samples/dap-demo/run.sh | 59 -- py/samples/dap-demo/src/dap_demo/__init__.py | 514 ------------------ py/uv.lock | 27 - 13 files changed, 70 insertions(+), 1690 deletions(-) delete mode 100644 py/samples/dap-demo/README.md delete mode 100644 py/samples/dap-demo/pyproject.toml delete mode 100755 py/samples/dap-demo/run.sh delete mode 100644 py/samples/dap-demo/src/dap_demo/__init__.py diff --git a/py/GEMINI.md b/py/GEMINI.md index e07d3a39ad..77fec3511e 100644 --- a/py/GEMINI.md +++ b/py/GEMINI.md @@ -196,7 +196,6 @@ with urllib.request.urlopen(url) as response: # ❌ Blocking! return response.read() - # CORRECT - non-blocking async def fetch_data(url: str) -> bytes: async with httpx.AsyncClient() as client: @@ -212,7 +211,6 @@ with open(path) as f: # ❌ Blocking! return f.read() - # CORRECT - non-blocking async def read_file(path: str) -> str: async with aiofiles.open(path, encoding='utf-8') as f: @@ -235,14 +233,12 @@ ```python from genkit.core.http_client import get_cached_client - # WRONG - creates new client per request (connection overhead) async def call_api(url: str) -> dict: async with httpx.AsyncClient() as client: response = await client.get(url) return response.json() - # WRONG - stores client at init time (event loop binding issues) class MyPlugin: def __init__(self): @@ -252,7 +248,6 @@ response = await self._client.get(url) # May fail in different loop return response.json() - # CORRECT - uses per-event-loop cached client async def call_api(url: str, token: str) -> dict: # For APIs with expiring tokens, pass auth headers per-request @@ -263,7 +258,6 @@ response = await client.get(url, headers={'Authorization': f'Bearer {token}'}) return response.json() - # CORRECT - for static auth (API keys that don't expire) async def call_api_static_auth(url: str) -> dict: client = get_cached_client( @@ -823,11 +817,9 @@ Python-specific development and release scripts: ```python from pydantic import BaseModel, Field - class MyFlowInput(BaseModel): prompt: str = Field(default='Hello world', description='User prompt') - @ai.flow() async def my_flow(input: MyFlowInput) -> str: return await ai.generate(prompt=input.prompt) @@ -839,18 +831,19 @@ Python-specific development and release scripts: from typing import Annotated from pydantic import Field - @ai.flow() async def my_flow( prompt: Annotated[str, Field(default='Hello world')] = 'Hello world', - ) -> str: ... + ) -> str: + ... ``` **Wrong** (defaults won't show in Dev UI): ```python @ai.flow() - async def my_flow(prompt: str = 'Hello world') -> str: ... + async def my_flow(prompt: str = 'Hello world') -> str: + ... ``` * **Sample Media URLs**: When samples need to reference an image URL (e.g., for @@ -886,14 +879,13 @@ Python-specific development and release scripts: ```python import asyncio - async def main(): - # ... - await asyncio.Event().wait() - + # ... + await asyncio.Event().wait() # At the bottom of main.py if __name__ == '__main__': + ai.run_main(main()) ``` @@ -985,7 +977,6 @@ When developing Genkit plugins, follow these additional guidelines: system: str | None = None # System prompt override metadata: dict[str, Any] | None = None # Request metadata - # Bad: Only basic parameters class AnthropicModelConfig(BaseModel): temperature: float | None = None @@ -1004,7 +995,6 @@ When developing Genkit plugins, follow these additional guidelines: guardrailVersion: Version of the guardrail (default: "DRAFT"). performanceConfig: Controls latency optimization settings. """ - guardrailIdentifier: str | None = None guardrailVersion: str | None = None performanceConfig: PerformanceConfiguration | None = None @@ -1076,15 +1066,14 @@ deployment environment. This makes the code more portable and user-friendly glob # Good: Named constant with clear purpose DEFAULT_OLLAMA_SERVER_URL = 'http://127.0.0.1:11434' - class OllamaPlugin: def __init__(self, server_url: str | None = None): self.server_url = server_url or DEFAULT_OLLAMA_SERVER_URL - # Bad: Inline hardcoded value class OllamaPlugin: - def __init__(self, server_url: str = 'http://127.0.0.1:11434'): ... + def __init__(self, server_url: str = 'http://127.0.0.1:11434'): + ... ``` * **Region-Agnostic Helpers**: For cloud services with regional endpoints, provide helper @@ -1099,9 +1088,9 @@ deployment environment. This makes the code more portable and user-friendly glob raise ValueError('Region is required.') # Map region to prefix... - # Bad: Hardcoded US default - def get_inference_profile_prefix(region: str = 'us-east-1') -> str: ... + def get_inference_profile_prefix(region: str = 'us-east-1') -> str: + ... ``` * **Documentation Examples**: In documentation and docstrings, use placeholder values @@ -1109,10 +1098,10 @@ deployment environment. This makes the code more portable and user-friendly glob ```python # Good: Clear placeholder - endpoint = 'https://your-resource.openai.azure.com/' + endpoint='https://your-resource.openai.azure.com/' # Bad: Looks like it might work - endpoint = 'https://eastus.api.example.com/' + endpoint='https://eastus.api.example.com/' ``` * **What IS Acceptable to Hardcode**: @@ -1313,7 +1302,6 @@ plugins/{name}/tests/ ```python from unittest.mock import AsyncMock, patch - @patch('genkit.plugins.mistral.models.Mistral') async def test_generate(mock_client_class): mock_client = AsyncMock() @@ -2318,7 +2306,6 @@ When mocking HTTP clients in tests, mock `get_cached_client` instead of ```python from unittest.mock import AsyncMock, patch - @patch('my_module.get_cached_client') async def test_api_call(mock_get_client): mock_client = AsyncMock() @@ -3092,171 +3079,3 @@ done **Exception:** `bin/install_cli` intentionally omits `pipefail` as it's a user-facing install script that handles errors differently for better user experience. - -### Session Learnings (2026-02-05): DAP, ASGI Types, and Sample Structure - -This session covered several important patterns for Genkit Python development. - -#### Dynamic Action Provider (DAP) Best Practices - -**1. DAP Tools Are NOT in the Global Registry** - -Dynamic tools created via `ai.dynamic_tool()` are intentionally NOT registered in the -global registry. This means you cannot pass them to `ai.generate(tools=[...])` by name. - -```python -# ❌ WRONG - dynamic tools aren't in the registry -result = await ai.generate( - prompt=query, - tools=[t.name for t in dynamic_tools], # Names won't resolve! -) - -# ✅ CORRECT - invoke dynamic tools directly -tool = await my_dap.get_action('tool', 'get_weather') -result = await tool.arun(input) -``` - -**2. Combining Multiple DAP Tool Results** - -When a query might match multiple tools, collect results instead of returning early: - -```python -# ❌ WRONG - returns after first match -if tool_a and matches_a: - return await tool_a.arun(input) -if tool_b and matches_b: - return await tool_b.arun(input) - -# ✅ CORRECT - collect all matching results -results: list[str] = [] -if tool_a and matches_a: - results.append(str((await tool_a.arun(input)).response)) -if tool_b and matches_b: - results.append(str((await tool_b.arun(input)).response)) -return ' | '.join(results) if results else 'No matches' -``` - -**3. Use asyncio.gather for Concurrent DAP Fetches** - -When fetching from multiple DAPs, use `asyncio.gather` for efficiency: - -```python -# ✅ Concurrent - efficient -weather_cache, finance_cache = await asyncio.gather( - weather_dap._cache.get_or_fetch(), # noqa: SLF001 - finance_dap._cache.get_or_fetch(), # noqa: SLF001 -) - -# ❌ Sequential - slower -weather_cache = await weather_dap._cache.get_or_fetch() -finance_cache = await finance_dap._cache.get_or_fetch() -``` - -#### Sample Package Structure - -**pyproject.toml `packages` vs Runtime Execution** - -The `[tool.hatch.build.targets.wheel].packages` setting is for **wheel building**, not -runtime execution. Samples should be run directly: - -```toml -# pyproject.toml -[tool.hatch.build.targets.wheel] -packages = ["src/dap_demo"] # For wheel builds -``` - -```bash -# run.sh - direct file execution (NOT -m module) -uv run src/dap_demo/__init__.py "$@" -``` - -When using `-m` module execution, Python requires the module to be in `PYTHONPATH`. -For samples, direct file execution is simpler and matches other samples. - -#### ASGI Type Compatibility - -**Protocol-Based Types for Framework Portability** - -Use `typing.Protocol` instead of Union types for ASGI compatibility across frameworks: - -```python -# ✅ CORRECT - Protocol-based types work with any ASGI framework -from typing import Protocol, runtime_checkable -from collections.abc import Awaitable, Callable, MutableMapping - -Scope = MutableMapping[str, Any] -Receive = Callable[[], Awaitable[MutableMapping[str, Any]]] -Send = Callable[[MutableMapping[str, Any]], Awaitable[None]] - - -@runtime_checkable -class ASGIApp(Protocol): - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: ... -``` - -**Framework-Specific Middleware Uses Native Types** - -When extending framework middleware classes (e.g., Litestar's `AbstractMiddleware`), -use that framework's native types, not the portable ASGI protocols: - -```python -# For Litestar middleware, use litestar.types -from litestar.middleware.base import AbstractMiddleware -from litestar.types import Receive, Scope, Send # Framework-specific - - -class MyMiddleware(AbstractMiddleware): - async def __call__( - self, - scope: Scope, # litestar.types.Scope - receive: Receive, # litestar.types.Receive - send: Send, # litestar.types.Send - ) -> None: ... -``` - -**Application Type Uses Any** - -External frameworks define incompatible `Application` types, so use `Any`: - -```python -# Intentional - frameworks have incompatible Application types -Application = Any -"""Type alias for ASGI application objects. - -Note: Uses Any because external frameworks define their own ASGI types -that aren't structurally compatible with our Protocol. -""" -``` - -#### Optional Dependencies in Lint Configuration - -For optional dependencies used only in type hints, add them to the `lint` dependency -group rather than using inline ignore comments: - -```toml -# In pyproject.toml [project.optional-dependencies] -lint = [ - "litestar>=2.0.0", # For web/typing.py type resolution -] -``` - -This allows type checkers to resolve imports during CI while keeping the package -optional for runtime. - -#### Documentation Style: Avoid Section Marker Comments - -Per GEMINI.md guidelines, avoid boilerplate section marker comments: - -```python -# ❌ WRONG - boilerplate markers -# ============================================================================= -# ASGI Protocol Types -# ============================================================================= - -# ✅ CORRECT - descriptive comment only -# These Protocol-based types follow the ASGI specification and are compatible -# with any ASGI framework. -``` - -Comments should tell **why**, not **what**. Section markers add visual noise -without adding information. diff --git a/py/engdoc/parity-analysis/roadmap.md b/py/engdoc/parity-analysis/roadmap.md index 02e3e3c44a..cd788d392e 100644 --- a/py/engdoc/parity-analysis/roadmap.md +++ b/py/engdoc/parity-analysis/roadmap.md @@ -4,7 +4,7 @@ This document organizes the identified gaps into executable milestones with depe --- -## Current Status (Updated 2026-02-05) +## Current Status (Updated 2026-01-30) > [!IMPORTANT] > **Overall Parity: ~99% Complete** - Nearly all milestones done! @@ -20,7 +20,6 @@ This document organizes the identified gaps into executable milestones with depe | **M4: Telemetry** | ✅ Complete | RealtimeSpanProcessor, flushTracing, AdjustingTraceExporter, GCP parity | | **M5: Advanced** | ✅ Complete | embed_many ✅, define_simple_retriever ✅, define_background_model ✅ | | **M6: Media Models** | ✅ Complete | Veo, Lyria, TTS, Gemini Image models | -| **M7: DAP Core** | ✅ Complete | Dynamic Action Provider core implementation | ### Remaining Work @@ -28,131 +27,17 @@ This document organizes the identified gaps into executable milestones with depe |----------|------|--------|--------| | **P0** | Testing Infrastructure (`genkit.testing`) | S | ✅ Complete | | **P0** | Context Caching (google-genai) | M | ✅ Complete | -| **P0** | DAP Core Implementation | M | ✅ Complete | | **P1** | `define_background_model()` | M | ✅ Complete | | **P1** | Veo support in google-genai plugin | M | ✅ Complete | | **P1** | TTS (Text-to-Speech) models | S | ✅ Complete | | **P1** | Gemini Image models | S | ✅ Complete | | **P1** | Lyria audio generation (Vertex AI) | S | ✅ Complete | -| **P1** | DAP DevUI Integration (`listResolvableActions`) | M | ✅ Complete | -| **P1** | DAP Registry Key Parsing | S | ✅ Complete | | **P1** | Live/Realtime API | L | ❌ Not Started | | **P2** | Multi-agent sample | M | ❌ Not Started | | **P2** | MCP sample | M | ❌ Not Started | --- -## M11: Dynamic Action Provider (DAP) Analysis (2026-02-05) - -> [!NOTE] -> DAP enables external systems (e.g., MCP servers) to provide actions at runtime. -> **Updated 2026-02-05:** Python implementation now at 100% parity with JS PR #4050. - -### JS PR #4050 Alignment (Complete) - -The Python DAP implementation has been updated to match the latest JavaScript -changes from PR #4050 (merged 2026-02-05): - -| Change | JS (PR #4050) | Python | Status | -|--------|---------------|--------|--------| -| **DAP Action Signature** | `z.void()` input, `z.array(ActionMetadataSchema)` output | `None` input, `list[ActionMetadata]` output | ✅ | -| **Cache Pattern** | `setDap()` / `setValue()` pattern | `set_dap()` / `set_value()` pattern | ✅ | -| **transform_dap_value** | Returns flat `ActionMetadata[]` | Returns flat `list[ActionMetadataLike]` | ✅ | -| **Metadata Format** | Includes `name`, `description` explicitly | Same | ✅ | -| **Action Internal Logic** | Action calls DAP fn directly, caches result | Same | ✅ | - -### Feature Comparison: JS vs Python - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ DAP FEATURE COMPARISON: JS vs PYTHON │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ CORE FUNCTIONALITY │ -├────────────────────────────┬────────────────────┬────────────────────┬──────┤ -│ Feature │ JS (Canonical) │ Python │Status│ -├────────────────────────────┼────────────────────┼────────────────────┼──────┤ -│ define_dynamic_action_prov │ ✅ dynamic-action- │ ✅ blocks/dap.py │ ✅ │ -│ │ provider.ts │ │ │ -│ is_dynamic_action_provider │ ✅ Lines 127-131 │ ✅ Lines 406-421 │ ✅ │ -│ DynamicActionProvider class│ ✅ Lines 100-125 │ ✅ Lines 289-403 │ ✅ │ -│ SimpleCache with TTL │ ✅ Lines 31-98 │ ✅ Lines 175-266 │ ✅ │ -│ transform_dap_value │ ✅ Lines 150-154 │ ✅ Lines 269-286 │ ✅ │ -├────────────────────────────┴────────────────────┴────────────────────┴──────┤ -│ │ -│ DAP METHODS │ -├────────────────────────────┬────────────────────┬────────────────────┬──────┤ -│ getAction(type, name) │ ✅ registry lookup │ ✅ Lines 317-336 │ ✅ │ -│ listActionMetadata(t,n) │ ✅ wildcard/prefix │ ✅ Lines 338-375 │ ✅ │ -│ getActionMetadataRecord(p) │ ✅ reflection API │ ✅ Lines 377-403 │ ✅ │ -│ invalidateCache() │ ✅ manual cache │ ✅ Lines 313-315 │ ✅ │ -│ get_or_fetch(skip_trace) │ ✅ async fetch │ ✅ Lines 205-238 │ ✅ │ -├────────────────────────────┴────────────────────┴────────────────────┴──────┤ -│ │ -│ CACHE CONFIGURATION │ -├────────────────────────────┬────────────────────┬────────────────────┬──────┤ -│ DapConfig interface │ ✅ name, desc, ttl │ ✅ DapConfig class │ ✅ │ -│ DapCacheConfig (TTL) │ ✅ ttlMillis │ ✅ ttl_millis │ ✅ │ -│ Default TTL (3000ms) │ ✅ 3000ms default │ ✅ 3000ms default │ ✅ │ -│ Negative TTL (no cache) │ ✅ ttlMillis < 0 │ ✅ ttl_millis < 0 │ ✅ │ -├────────────────────────────┴────────────────────┴────────────────────┴──────┤ -│ │ -│ REGISTRY INTEGRATION │ -├────────────────────────────┬────────────────────┬────────────────────┬──────┤ -│ ActionKind/ActionType │ ✅ 'dynamic-action │ ✅ DYNAMIC_ACTION_ │ ✅ │ -│ │ -provider' │ PROVIDER │ │ -│ DAP fallback in resolve │ ✅ getDynamicAction│ ✅ registry.py │ ✅ │ -│ │ │ lines 435-456 │ │ -│ listResolvableActions DAP │ ✅ Includes DAP │ ✅ list_resolvable_ │ ✅ │ -│ │ actions in list │ _actions() │ │ -│ resolveActionNames (DAP) │ ✅ Wildcard expand │ ✅ resolve_action_ │ ✅ │ -│ │ │ _names() │ │ -│ parseRegistryKey for DAP │ ✅ Parses DAP keys │ ✅ parse_registry_ │ ✅ │ -│ │ │ _key() │ │ -├────────────────────────────┴────────────────────┴────────────────────┴──────┤ -│ │ -│ TEST COVERAGE (13 core tests matching JS exactly + 7 additional) │ -├────────────────────────────┬────────────────────┬────────────────────┬──────┤ -│ gets specific action │ ✅ Test exists │ ✅ dap_test.py │ ✅ │ -│ lists action metadata │ ✅ Test exists │ ✅ dap_test.py │ ✅ │ -│ caches the actions │ ✅ Test exists │ ✅ dap_test.py │ ✅ │ -│ invalidates the cache │ ✅ Test exists │ ✅ dap_test.py │ ✅ │ -│ respects cache ttl │ ✅ Test exists │ ✅ dap_test.py │ ✅ │ -│ lists with prefix │ ✅ Test exists │ ✅ dap_test.py │ ✅ │ -│ lists exact match │ ✅ Test exists │ ✅ dap_test.py │ ✅ │ -│ gets action metadata rec │ ✅ Test exists │ ✅ dap_test.py │ ✅ │ -│ handles concurrent reqs │ ✅ Test exists │ ✅ dap_test.py │ ✅ │ -│ handles fetch errors │ ✅ Test exists │ ✅ dap_test.py │ ✅ │ -│ skips trace when requested │ ✅ Test exists │ ✅ via skip flag │ ✅ │ -│ identifies DAPs │ ✅ Test exists │ ✅ dap_test.py │ ✅ │ -│ Additional Python tests │ - │ ✅ 8 extra tests │ ✅ │ -└────────────────────────────┴────────────────────┴────────────────────┴──────┘ -``` - -### DAP Completed Gaps ✅ - -| Gap | JS Location | Impact | Status | -|-----|-------------|--------|--------| -| `listResolvableActions` DAP | `registry.ts:383-398` | DevUI shows DAP actions | ✅ `list_resolvable_actions()` | -| `resolveActionNames` | `registry.ts:196-212` | Wildcard expansion | ✅ `resolve_action_names()` | -| `parseRegistryKey` DAP | `registry.ts:96-141` | DAP key format parsing | ✅ `parse_registry_key()` | - -### Implementation Notes - -**Python DAP Core (Complete - 100% Parity with JS PR #4050):** -- `py/packages/genkit/src/genkit/blocks/dap.py` - Full implementation -- `py/packages/genkit/tests/genkit/blocks/dap_test.py` - 23 tests (all passing) -- Documentation with ELI5 explanations and ASCII diagrams in docstrings -- Sample: `py/samples/dap-demo/` - Comprehensive demonstration - -**Registry Integration (Complete 2026-02-05):** -- `parse_registry_key()` - Parses DAP-style keys like `/dynamic-action-provider/mcp-host:tool/mytool` -- `resolve_action_names()` - Expands wildcard keys via DAP -- `list_resolvable_actions()` - Lists all actions including DAP-provided ones -- `is_action_type()` - Helper to validate action type strings - ---- - ## Remaining Gaps (Prioritized) > [!NOTE] @@ -162,14 +47,11 @@ changes from PR #4050 (merged 2026-02-05): |-----|-------------|----------|--------| | **Testing Infrastructure** | JS has `echoModel`, `ProgrammableModel`, `TestAction` for unit testing. | **P0** | ✅ Complete | | **Context Caching** | `ai.cacheContent()`, `cachedContent` option in generate | **P0** | ✅ Complete | -| **DAP Core** | Dynamic Action Provider implementation | **P0** | ✅ Complete | | **define_background_model** | Core API for background models (Veo, etc.) | **P1** | ✅ Complete | | **Veo plugin support** | Add `veo.py` to google-genai plugin (JS has `veo.ts`) | **P1** | ✅ Complete | | **TTS models** | Text-to-speech Gemini models (gemini-*-tts) | **P1** | ✅ Complete | | **Gemini Image models** | Native image generation (gemini-*-image) | **P1** | ✅ Complete | | **Lyria audio generation** | Audio generation via Vertex AI (lyria-002) | **P1** | ✅ Complete | -| **DAP DevUI Integration** | `listResolvableActions` includes DAP-provided actions | **P1** | ✅ Complete | -| **DAP Key Parsing** | `parseRegistryKey` for DAP format (`dap:type/name`) | **P2** | ✅ Complete | | **Live/Realtime API** | Google GenAI Live API for real-time streaming | **P1** | ❌ Not Started | | **CLI/Tooling Parity** | `genkit` CLI commands and Python project behavior | Medium | ⚠️ Mostly Working | | **Error Types** | Python error hierarchy parity check | Low | ⚠️ Needs Review | diff --git a/py/packages/genkit/src/genkit/blocks/dap.py b/py/packages/genkit/src/genkit/blocks/dap.py index f817429bf5..23d6ac638a 100644 --- a/py/packages/genkit/src/genkit/blocks/dap.py +++ b/py/packages/genkit/src/genkit/blocks/dap.py @@ -105,7 +105,6 @@ async def get_mcp_tools(): See Also: - MCP Plugin: genkit.plugins.mcp for Model Context Protocol integration - JS Implementation: js/core/src/dynamic-action-provider.ts - - Sample: py/samples/dap-demo for comprehensive examples """ import asyncio @@ -178,25 +177,22 @@ class SimpleCache: This cache ensures that concurrent requests for the same data share a single fetch operation, preventing thundering herd problems. - - Updated to match JS implementation (PR #4050): - - Cache is created before DAP action - - set_value method for external value assignment - - setDap pattern for deferred DAP reference """ def __init__( self, + dap: 'DynamicActionProvider', config: DapConfig, dap_fn: DapFn, ) -> None: """Initialize the cache. Args: + dap: The parent DAP action. config: DAP configuration including TTL. dap_fn: Function to fetch actions from the external source. """ - self._dap: DynamicActionProvider | None = None + self._dap = dap self._dap_fn = dap_fn self._value: DapValue | None = None self._expires_at: float | None = None @@ -206,23 +202,6 @@ def __init__( ttl = config.cache_config.ttl_millis if config.cache_config else None self._ttl_millis = 3000 if ttl is None or ttl == 0 else ttl - def set_dap(self, dap: 'DynamicActionProvider') -> None: - """Set the DAP reference (deferred initialization). - - Args: - dap: The parent DAP action provider. - """ - self._dap = dap - - def set_value(self, value: DapValue) -> None: - """Set cache value externally (called by DAP action). - - Args: - value: The DAP value to cache. - """ - self._value = value - self._expires_at = time.time() * 1000 + self._ttl_millis - async def get_or_fetch(self, skip_trace: bool = False) -> DapValue: """Get cached value or fetch fresh data if stale. @@ -261,10 +240,6 @@ async def get_or_fetch(self, skip_trace: bool = False) -> DapValue: async def _do_fetch(self, skip_trace: bool) -> DapValue: """Perform the actual fetch operation. - Updated to match JS implementation (PR #4050): - - If DAP is set and not skipping trace, run the action (which sets value) - - Otherwise, call dap_fn directly and set value - Args: skip_trace: If True, skip running the DAP action. @@ -272,15 +247,13 @@ async def _do_fetch(self, skip_trace: bool) -> DapValue: Fresh DAP value. """ try: - if self._dap is not None and not skip_trace: - # Run the DAP action - it calls set_value internally - await self._dap.action.arun(None) - else: - # Direct fetch without tracing - self.set_value(await self._dap_fn()) + self._value = await self._dap_fn() + self._expires_at = time.time() * 1000 + self._ttl_millis - if self._value is None: - raise ValueError('DAP value is None after fetch') + # Run the DAP action for tracing (unless skipped) + if not skip_trace: + metadata = transform_dap_value(self._value) + await self._dap.action.arun(metadata) return self._value except Exception: @@ -293,45 +266,24 @@ def invalidate(self) -> None: self._expires_at = None -def _create_action_metadata(action: Action[Any, Any]) -> dict[str, object]: - """Create metadata dict from an Action, with Action properties taking precedence. - - Copies action.metadata first, then overwrites name, description, kind, and - schemas from the Action object to ensure they are always correct. - - Args: - action: The action to create metadata for. - - Returns: - A metadata dictionary suitable for ActionMetadata. - """ - meta: dict[str, object] = dict(action.metadata) if action.metadata else {} - meta['name'] = action.name - meta['description'] = action.description - meta['kind'] = action.kind - meta['inputSchema'] = action.input_schema - meta['outputSchema'] = action.output_schema - return meta - - -def transform_dap_value(value: DapValue) -> list[ActionMetadataLike]: - """Transform DAP value to flat list of action metadata. - - Updated to match JS implementation (PR #4050): - - Returns flat list instead of grouped dict - - Matches ActionMetadataSchema structure +def transform_dap_value(value: DapValue) -> DapMetadata: + """Transform DAP value to metadata format for logging. Args: value: DAP value with actions. Returns: - Flat list of action metadata. + DAP metadata with action metadata. """ - metadata_list: list[ActionMetadataLike] = [] - for actions in value.values(): - for action in actions or []: - metadata_list.append(_create_action_metadata(action)) - return metadata_list + metadata: DapMetadata = {} + for action_type, actions in value.items(): + action_metadata_list: list[ActionMetadataLike] = [] + for action in actions: + # Action.metadata is dict[str, object] which satisfies ActionMetadataLike + meta: ActionMetadataLike = action.metadata if action.metadata else {} + action_metadata_list.append(meta) + metadata[action_type] = action_metadata_list + return metadata class DynamicActionProvider: @@ -345,20 +297,18 @@ def __init__( self, action: Action[Any, Any], config: DapConfig, - cache: SimpleCache, + dap_fn: DapFn, ) -> None: """Initialize the DAP. Args: action: The underlying DAP action. config: DAP configuration. - cache: The cache instance (created before action). + dap_fn: Function to fetch actions. """ self.action = action self.config = config - self._cache = cache - # Set the DAP reference in cache (deferred init pattern from JS) - cache.set_dap(self) + self._cache = SimpleCache(self, config, dap_fn) def invalidate_cache(self) -> None: """Invalidate the cache, forcing a fresh fetch on next access.""" @@ -409,7 +359,8 @@ async def list_action_metadata( metadata_list: list[ActionMetadataLike] = [] for action in actions: - metadata_list.append(_create_action_metadata(action)) + meta: ActionMetadataLike = action.metadata if action.metadata else {} + metadata_list.append(meta) # Match all if action_name == '*': @@ -447,7 +398,7 @@ async def get_action_metadata_record( if not action.name: raise ValueError(f'Invalid metadata when listing dynamic actions from {dap_prefix} - name required') key = f'{dap_prefix}:{action_type}/{action.name}' - dap_actions[key] = _create_action_metadata(action) + dap_actions[key] = action.metadata if action.metadata else {} return dap_actions @@ -481,11 +432,6 @@ def define_dynamic_action_provider( This is useful for integrating with external systems like MCP servers or plugin marketplaces. - Updated to match JS implementation (PR #4050): - - DAP action takes no input (None) and returns list[ActionMetadata] - - Action calls the DAP function and caches the result - - Cache is created before the action - Args: registry: The registry to register the DAP with. config: DAP configuration or just a name string. @@ -530,9 +476,6 @@ async def get_tools(): # Normalize config cfg = DapConfig(name=config) if isinstance(config, str) else config - # Create cache first (matches JS pattern from PR #4050) - cache = SimpleCache(cfg, fn) - # Create metadata with DAP type marker action_metadata = { **cfg.metadata, @@ -540,12 +483,9 @@ async def get_tools(): } # Define the underlying action - # Updated to match JS: takes no input, returns list of action metadata - # The action itself calls the DAP function and caches the result - async def dap_action(_input: None) -> list[ActionMetadataLike]: - dap_value = await fn() - cache.set_value(dap_value) - return transform_dap_value(dap_value) + # The action itself just returns its input (for logging purposes) + async def dap_action(input: DapMetadata) -> DapMetadata: + return input action = registry.register_action( name=cfg.name, @@ -556,10 +496,7 @@ async def dap_action(_input: None) -> list[ActionMetadataLike]: ) # Wrap in DynamicActionProvider - dap = DynamicActionProvider(action, cfg, cache) - - # Store reference so Registry.list_actions can access it for DevUI - action._dap_instance = dap # type: ignore[attr-defined] + dap = DynamicActionProvider(action, cfg, fn) return dap diff --git a/py/packages/genkit/src/genkit/core/action/__init__.py b/py/packages/genkit/src/genkit/core/action/__init__.py index 357dde99b3..4e47132989 100644 --- a/py/packages/genkit/src/genkit/core/action/__init__.py +++ b/py/packages/genkit/src/genkit/core/action/__init__.py @@ -20,7 +20,7 @@ from ._key import create_action_key, parse_action_key from ._tracing import SpanAttributeValue from ._util import parse_plugin_name_from_action_name -from .types import ActionKind, ActionResponse, is_action_type +from .types import ActionKind, ActionResponse __all__ = [ 'Action', @@ -30,7 +30,6 @@ 'ActionRunContext', 'SpanAttributeValue', 'create_action_key', - 'is_action_type', 'parse_action_key', 'parse_plugin_name_from_action_name', ] diff --git a/py/packages/genkit/src/genkit/core/action/types.py b/py/packages/genkit/src/genkit/core/action/types.py index 83b643ed8f..9dc9d9f472 100644 --- a/py/packages/genkit/src/genkit/core/action/types.py +++ b/py/packages/genkit/src/genkit/core/action/types.py @@ -59,22 +59,6 @@ class ActionKind(StrEnum): UTIL = 'util' -def is_action_type(value: str) -> bool: - """Check if a string is a valid ActionKind. - - Args: - value: The string to check. - - Returns: - True if the value is a valid ActionKind. - """ - try: - ActionKind(value) - return True - except ValueError: - return False - - ResponseT = TypeVar('ResponseT') diff --git a/py/packages/genkit/src/genkit/core/registry.py b/py/packages/genkit/src/genkit/core/registry.py index 7cf28800cc..ac7aa75986 100644 --- a/py/packages/genkit/src/genkit/core/registry.py +++ b/py/packages/genkit/src/genkit/core/registry.py @@ -30,7 +30,7 @@ import asyncio import threading from collections.abc import Awaitable, Callable -from typing import Protocol, cast +from typing import cast from dotpromptz.dotprompt import Dotprompt from pydantic import BaseModel @@ -43,7 +43,7 @@ SpanAttributeValue, parse_action_key, ) -from genkit.core.action.types import ActionKind, ActionName, is_action_type +from genkit.core.action.types import ActionKind, ActionName from genkit.core.logging import get_logger from genkit.core.plugin import Plugin from genkit.core.typing import ( @@ -88,127 +88,6 @@ ) -def _is_dap_action(action: Action) -> bool: - """Check if an action is a Dynamic Action Provider using duck typing. - - Uses metadata check to avoid circular import with genkit.blocks.dap. - - Args: - action: The action to check. - - Returns: - True if the action is a DAP. - """ - if hasattr(action, 'metadata') and isinstance(action.metadata, dict): - return action.metadata.get('type') == 'dynamic-action-provider' - return False - - -class DynamicActionProviderProtocol(Protocol): - """Protocol for Dynamic Action Provider interface. - - This protocol defines the interface required by Registry to work with DAPs - without creating a circular import with genkit.blocks.dap. - """ - - async def get_action_metadata_record(self, dap_prefix: str) -> dict[str, dict]: - """Get action metadata record for DevUI listing. - - Args: - dap_prefix: The DAP prefix (e.g., '/dynamic-action-provider/my-dap'). - - Returns: - A dictionary mapping action keys to metadata dicts. - """ - ... - - async def list_action_metadata(self, action_type: str, action_name: str) -> list[dict]: - """List action metadata matching the type and name pattern. - - Args: - action_type: The action type (e.g., 'tool'). - action_name: The action name pattern (supports '*' wildcard). - - Returns: - A list of action metadata dicts. - """ - ... - - -class ParsedRegistryKey(BaseModel): - """Parsed registry key containing action type, name, and optional DAP host. - - Registry keys can be in several formats: - - Standard: /model/googleai/gemini-2.0-flash - - DAP: /dynamic-action-provider/mcp-host:tool/my-tool - - Util: /util/generate - - Attributes: - action_type: The type of action (e.g., 'model', 'tool'). - action_name: The name of the action. - plugin_name: Optional plugin name for namespaced actions. - dynamic_action_host: Optional DAP host name for dynamic actions. - """ - - action_type: str - action_name: str - plugin_name: str | None = None - dynamic_action_host: str | None = None - - -def parse_registry_key(registry_key: str) -> ParsedRegistryKey | None: - """Parse a registry key into its component parts. - - Supports multiple key formats: - - DAP format: '/dynamic-action-provider/mcp-host:tool/mytool' - - Standard format: '/model/googleai/gemini-2.0-flash' - - Prompt format: '/prompt/my-plugin/folder/my-prompt' - - Util format: '/util/generate' - - Args: - registry_key: The registry key string to parse. - - Returns: - ParsedRegistryKey containing the parsed components, or None if invalid. - """ - if registry_key.startswith('/dynamic-action-provider'): - # DAP format: '/dynamic-action-provider/mcp-host:tool/mytool' or 'mcp-host:tool/*' - key_tokens = registry_key.split(':', 1) - host_tokens = key_tokens[0].split('/') - if len(host_tokens) < 3: - return None - if len(key_tokens) < 2: - return ParsedRegistryKey( - action_type=ActionKind.DYNAMIC_ACTION_PROVIDER, - action_name=host_tokens[2], - ) - tokens = key_tokens[1].split('/') - if len(tokens) < 2 or not is_action_type(tokens[0]): - return None - return ParsedRegistryKey( - dynamic_action_host=host_tokens[2], - action_type=tokens[0], - action_name='/'.join(tokens[1:]), - ) - - tokens = registry_key.split('/') - if len(tokens) < 3: - # Invalid key format - return None - # Format: /model/googleai/gemini-2.0-flash or /prompt/my-plugin/folder/my-prompt - if len(tokens) >= 4: - return ParsedRegistryKey( - action_type=tokens[1], - plugin_name=tokens[2], - action_name='/'.join(tokens[3:]), - ) - # Format: /util/generate - return ParsedRegistryKey( - action_type=tokens[1], - action_name=tokens[2], - ) - - class Registry: """Central repository for Genkit resources. @@ -577,78 +456,6 @@ async def resolve_action(self, kind: ActionKind, name: str) -> Action | None: return None - async def resolve_action_names(self, key: str) -> list[str]: - """Resolve a registry key to a list of matching action names. - - Supports wildcard expansion for DAP actions - (e.g., '/dynamic-action-provider/mcp-host:tool/*'). - - Args: - key: The registry key, potentially with wildcards. - - Returns: - List of fully-qualified action names matching the key. - """ - parsed_key = parse_registry_key(key) - if parsed_key and parsed_key.dynamic_action_host: - # DAP key - resolve via the DAP - host_id = f'/dynamic-action-provider/{parsed_key.dynamic_action_host}' - with self._lock: - dap_entries = self._entries.get(ActionKind.DYNAMIC_ACTION_PROVIDER, {}) - dap_action = dap_entries.get(parsed_key.dynamic_action_host) - - if dap_action is None: - return [] - - if not _is_dap_action(dap_action): - return [] - - # Get the DynamicActionProvider wrapper (uses Protocol for type hint) - dap: DynamicActionProviderProtocol | None = getattr(dap_action, '_dap_instance', None) - if dap is None: - return [] - - metadata_list = await dap.list_action_metadata(parsed_key.action_type, parsed_key.action_name) - return [f'{host_id}:{parsed_key.action_type}/{m.get("name", "")}' for m in metadata_list] - - # Standard key - just return it if the action exists - if await self.lookup_action_by_key(key): - return [key] - return [] - - async def lookup_action_by_key(self, key: str) -> Action | None: - """Lookup an action by its full registry key. - - This is a simple lookup that doesn't trigger any resolution. - Use resolve_action_by_key for full resolution with plugin initialization. - - Args: - key: The full registry key. - - Returns: - The Action if found, None otherwise. - """ - parsed = parse_registry_key(key) - if not parsed: - return None - - try: - kind = ActionKind(parsed.action_type) - except ValueError: - return None - - if parsed.plugin_name: - name = f'{parsed.plugin_name}/{parsed.action_name}' - else: - name = parsed.action_name - - with self._lock: - if kind not in self._entries: - return None - # pyrefly: ignore[bad-index] - kind is ActionKind, not plain StrEnum - kind_entries = self._entries[kind] - return kind_entries.get(name) - async def resolve_action_by_key(self, key: str) -> Action | None: """Resolve an action using its combined key string. @@ -675,9 +482,6 @@ async def list_actions(self, allowed_kinds: list[ActionKind] | None = None) -> l plugins. It does NOT trigger plugin initialization and does NOT consult the registry's internal action store. - For a full list including registered actions and DAP-provided actions, - use `list_resolvable_actions` instead. - Args: allowed_kinds: Optional list of action kinds to filter by. @@ -705,86 +509,6 @@ async def list_actions(self, allowed_kinds: list[ActionKind] | None = None) -> l metas.append(meta) return metas - async def list_resolvable_actions(self, allowed_kinds: list[ActionKind] | None = None) -> list[ActionMetadata]: - """List all resolvable actions including registered and DAP-provided actions. - - This method returns a comprehensive list of all actions: - 1. Actions advertised by plugins (via list_actions) - 2. Actions already registered in the registry - 3. Actions provided by Dynamic Action Providers (DAPs) - - This is the Python equivalent of JS `listResolvableActions` and is used - by the DevUI to show all available actions. - - Args: - allowed_kinds: Optional list of action kinds to filter by. - - Returns: - A list of ActionMetadata objects describing available actions. - - Raises: - ValueError: If a plugin returns invalid ActionMetadata. - """ - metas: list[ActionMetadata] = [] - seen_names: set[str] = set() - - # Get plugin actions first - plugin_metas = await self.list_actions(allowed_kinds) - for meta in plugin_metas: - if meta.name not in seen_names: - metas.append(meta) - seen_names.add(meta.name) - - # Get all registered actions including DAP actions - with self._lock: - all_entries = {k: dict(v) for k, v in self._entries.items()} - - for _kind, actions_dict in all_entries.items(): - for _name, action in actions_dict.items(): - # Add static action metadata if not already seen - if action.name not in seen_names: - # Build metadata dict with all available fields from the action. - # Using model_validate ensures we include schemas for DevUI. - meta_dict: dict[str, object] = { - 'name': action.name, - 'kind': action.kind, - 'description': action.description, - 'inputSchema': action.input_schema, - 'outputSchema': action.output_schema, - } - # Include any additional metadata from the action - if action.metadata: - meta_dict['metadata'] = action.metadata - meta = ActionMetadata.model_validate(meta_dict) - if allowed_kinds and meta.kind not in allowed_kinds: - continue - metas.append(meta) - seen_names.add(action.name) - - # If this is a DAP, include its dynamic actions - if _is_dap_action(action): - try: - dap_prefix = f'/{action.kind}/{action.name}' - # Get the DynamicActionProvider wrapper (uses Protocol for type hint) - dap: DynamicActionProviderProtocol | None = getattr(action, '_dap_instance', None) - if dap: - dap_metadata = await dap.get_action_metadata_record(dap_prefix) - for _dap_key, dap_meta in dap_metadata.items(): - if _dap_key not in seen_names: - # Create a mutable copy to set the fully-qualified name. - # Using model_validate preserves all metadata fields like schemas. - meta_dict = dict(dap_meta) - meta_dict['name'] = _dap_key - dap_action_meta = ActionMetadata.model_validate(meta_dict) - if allowed_kinds and dap_action_meta.kind not in allowed_kinds: - continue - metas.append(dap_action_meta) - seen_names.add(_dap_key) - except Exception as e: - logger.error(f'Error listing actions for DAP {action.name}: {e}') - - return metas - def register_schema(self, name: str, schema: dict[str, object], schema_type: type[BaseModel] | None = None) -> None: """Registers a schema by name. diff --git a/py/packages/genkit/tests/genkit/blocks/dap_test.py b/py/packages/genkit/tests/genkit/blocks/dap_test.py index d57eed5a83..51e52e0d64 100644 --- a/py/packages/genkit/tests/genkit/blocks/dap_test.py +++ b/py/packages/genkit/tests/genkit/blocks/dap_test.py @@ -56,8 +56,7 @@ ) from genkit.core.action import Action from genkit.core.action.types import ActionKind -from genkit.core.error import GenkitError -from genkit.core.registry import Registry, parse_registry_key +from genkit.core.registry import Registry @pytest.fixture @@ -148,9 +147,8 @@ async def dap_fn() -> DapValue: metadata = await dap.list_action_metadata('tool', '*') assert len(metadata) == 2 - # New API includes name/description in returned metadata - assert metadata[0].get('name') == 'tool1' - assert metadata[1].get('name') == 'tool2' + assert metadata[0] == tool1.metadata + assert metadata[1] == tool2.metadata assert call_count == 1 @@ -250,9 +248,8 @@ async def dap_fn() -> DapValue: metadata = await dap.list_action_metadata('tool', 'tool*') assert len(metadata) == 2 - # New API includes name/description in returned metadata - assert metadata[0].get('name') == 'tool1' - assert metadata[1].get('name') == 'tool2' + assert metadata[0] == tool1.metadata + assert metadata[1] == tool2.metadata assert call_count == 1 @@ -273,8 +270,7 @@ async def dap_fn() -> DapValue: metadata = await dap.list_action_metadata('tool', 'tool1') assert len(metadata) == 1 - # New API includes name/description in returned metadata - assert metadata[0].get('name') == 'tool1' + assert metadata[0] == tool1.metadata assert call_count == 1 @@ -300,10 +296,9 @@ async def dap_fn() -> DapValue: assert 'dap/my-dap:tool/tool1' in record assert 'dap/my-dap:tool/tool2' in record assert 'dap/my-dap:flow/tool1' in record - # New API returns dict with name/description included - assert record['dap/my-dap:tool/tool1'].get('name') == 'tool1' - assert record['dap/my-dap:tool/tool2'].get('name') == 'tool2' - assert record['dap/my-dap:flow/tool1'].get('name') == 'tool1' + assert record['dap/my-dap:tool/tool1'] == tool1.metadata + assert record['dap/my-dap:tool/tool2'] == tool2.metadata + assert record['dap/my-dap:flow/tool1'] == tool1.metadata assert call_count == 1 @@ -332,16 +327,18 @@ async def dap_fn() -> DapValue: metadata1, metadata2 = results assert len(metadata1) == 2 assert len(metadata2) == 2 - # New API includes name/description in returned metadata - assert metadata1[0].get('name') == 'tool1' - assert metadata2[0].get('name') == 'tool1' + assert metadata1[0] == tool1.metadata + assert metadata2[0] == tool1.metadata # Only one fetch should have occurred assert call_count == 1 @pytest.mark.asyncio async def test_handles_fetch_errors(registry: Registry, tool1: Action, tool2: Action) -> None: - """Test that DAP raises GenkitError on fetch failure.""" + """Test error handling and cache invalidation on fetch failure. + + Corresponds to JS test: 'handles fetch errors' + """ call_count = 0 async def dap_fn() -> DapValue: @@ -353,8 +350,8 @@ async def dap_fn() -> DapValue: dap = define_dynamic_action_provider(registry, 'my-dap', dap_fn) - # First call should raise (wrapped in GenkitError by action tracing) - with pytest.raises(GenkitError): + # First call should raise + with pytest.raises(RuntimeError, match='Fetch failed'): await dap.list_action_metadata('tool', '*') assert call_count == 1 @@ -382,19 +379,15 @@ async def dap_fn() -> DapValue: def test_transform_dap_value(tool1: Action, tool2: Action) -> None: - """Test the transform_dap_value utility function. - - Updated for PR #4050 parity: returns flat list instead of grouped dict. - """ + """Test the transform_dap_value utility function.""" value: DapValue = {'tool': [tool1, tool2]} metadata = transform_dap_value(value) - # New API returns flat list of action metadata - assert isinstance(metadata, list) - assert len(metadata) == 2 - assert metadata[0].get('name') == 'tool1' - assert metadata[1].get('name') == 'tool2' + assert 'tool' in metadata + assert len(metadata['tool']) == 2 + assert metadata['tool'][0] == tool1.metadata + assert metadata['tool'][1] == tool2.metadata def test_dap_config_string_normalization(registry: Registry) -> None: @@ -538,118 +531,3 @@ async def dap_fn() -> DapValue: with pytest.raises(ValueError, match='name required'): await dap.get_action_metadata_record('dap/my-dap') - - -@pytest.mark.asyncio -async def test_parse_registry_key_standard_format() -> None: - """Test parsing standard registry keys.""" - # Standard model key - parsed = parse_registry_key('/model/googleai/gemini-2.0-flash') - assert parsed is not None - assert parsed.action_type == 'model' - assert parsed.plugin_name == 'googleai' - assert parsed.action_name == 'gemini-2.0-flash' - assert parsed.dynamic_action_host is None - - # Util key (short format) - parsed = parse_registry_key('/util/generate') - assert parsed is not None - assert parsed.action_type == 'util' - assert parsed.action_name == 'generate' - assert parsed.plugin_name is None - - # Invalid key - parsed = parse_registry_key('invalid') - assert parsed is None - - -@pytest.mark.asyncio -async def test_parse_registry_key_dap_format() -> None: - """Test parsing DAP-style registry keys.""" - # DAP key with action type and name - parsed = parse_registry_key('/dynamic-action-provider/mcp-host:tool/my-tool') - assert parsed is not None - assert parsed.dynamic_action_host == 'mcp-host' - assert parsed.action_type == 'tool' - assert parsed.action_name == 'my-tool' - - # DAP key without action type (just host) - parsed = parse_registry_key('/dynamic-action-provider/mcp-host') - assert parsed is not None - assert parsed.action_type == 'dynamic-action-provider' - assert parsed.action_name == 'mcp-host' - - -@pytest.mark.asyncio -async def test_list_resolvable_actions_includes_dap(registry: Registry, tool1: Action, tool2: Action) -> None: - """Test that list_resolvable_actions includes DAP-provided actions.""" - call_count = 0 - - async def dap_fn() -> DapValue: - nonlocal call_count - call_count += 1 - return {'tool': [tool1, tool2]} - - define_dynamic_action_provider(registry, 'test-dap', dap_fn) - - # Get resolvable actions - metas = await registry.list_resolvable_actions() - - # Should include the DAP itself and the tools it provides (with full keys) - names = [m.name for m in metas] - assert 'test-dap' in names # The DAP action - assert '/dynamic-action-provider/test-dap:tool/tool1' in names # DAP-provided tool - assert '/dynamic-action-provider/test-dap:tool/tool2' in names # DAP-provided tool - - -@pytest.mark.asyncio -async def test_runs_action_with_transformed_metadata(registry: Registry, tool1: Action, tool2: Action) -> None: - """Test that the DAP action returns transformed metadata when run. - - Corresponds to JS test: 'runs the action with transformed metadata when fetching' - """ - - async def dap_fn() -> DapValue: - return {'tool': [tool1, tool2]} - - dap = define_dynamic_action_provider(registry, 'my-dap', dap_fn) - - # Fetch the DAP value through the cache (which runs the action) - await dap._cache.get_or_fetch() - - # Run the action directly and check the result is transformed metadata - result = await dap.action.arun(None) - metadata_list = result.response - - # Should return transformed metadata (list of ActionMetadata-like dicts) - assert len(metadata_list) == 2 - names = [m['name'] for m in metadata_list] - assert 'tool1' in names - assert 'tool2' in names - - -@pytest.mark.asyncio -async def test_skips_trace_when_requested(registry: Registry, tool1: Action, tool2: Action) -> None: - """Test that skipTrace parameter skips creating a trace. - - Corresponds to JS test: 'skips trace when requested' - """ - call_count = 0 - - async def dap_fn() -> DapValue: - nonlocal call_count - call_count += 1 - return {'tool': [tool1, tool2]} - - dap = define_dynamic_action_provider(registry, 'my-dap', dap_fn) - - # Fetch with skip_trace=True should call the dap_fn directly (not via action.run) - await dap._cache.get_or_fetch(skip_trace=True) - assert call_count == 1 - - # Invalidate cache - dap.invalidate_cache() - - # Fetch without skip_trace should also work (calls via action.run which calls dap_fn) - await dap._cache.get_or_fetch(skip_trace=False) - assert call_count == 2 diff --git a/py/pyproject.toml b/py/pyproject.toml index 3d733323bd..cbd6260b0d 100644 --- a/py/pyproject.toml +++ b/py/pyproject.toml @@ -136,7 +136,6 @@ amazon-bedrock-hello = { workspace = true } anthropic-hello = { workspace = true } cloudflare-workers-ai-hello = { workspace = true } compat-oai-hello = { workspace = true } -dap-demo = { workspace = true } deepseek-hello = { workspace = true } dev-local-vectorstore-hello = { workspace = true } evaluator-demo = { workspace = true } diff --git a/py/samples/dap-demo/README.md b/py/samples/dap-demo/README.md deleted file mode 100644 index 1e2bd7f2c9..0000000000 --- a/py/samples/dap-demo/README.md +++ /dev/null @@ -1,183 +0,0 @@ -# Dynamic Action Provider (DAP) Demo - -This sample demonstrates how to use **Dynamic Action Providers (DAPs)** to -dynamically provide tools at runtime, enabling integration with external -systems like MCP servers, plugin registries, or other dynamic tool sources. - -## What is a Dynamic Action Provider (DAP)? - -A DAP is a factory that creates actions (tools, flows, etc.) at runtime rather -than at startup. This is useful for: - -- **MCP Integration**: Connect to Model Context Protocol servers -- **Plugin Systems**: Load tools from external plugin registries -- **Multi-tenant Systems**: Provide tenant-specific tools dynamically -- **Feature Flags**: Enable/disable tools based on runtime configuration - -## Key Concepts - -| Concept | Description | -|---------|-------------| -| **DAP** | A "tool factory" that creates tools on-demand at runtime | -| **Dynamic Tool** | A tool created via `ai.dynamic_tool()` - not registered globally | -| **Cache** | DAP results are cached to avoid recreating tools on every request | -| **TTL** | Time-To-Live - how long cached tools remain valid before refresh | -| **Invalidation** | Manually clear the cache to force fresh tool creation | - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ DAP Flow │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Genkit │ │ DAP │ │ External │ │ -│ │ generate() │ ──► │ Cache │ ──► │ System │ │ -│ └──────────────┘ └──────────────┘ │ (API, DB) │ │ -│ │ │ └──────────────┘ │ -│ ▼ ▼ │ │ -│ ┌──────────────┐ ┌──────────────┐ │ │ -│ │ Model │ ◄── │ Dynamic │ ◄───────────┘ │ -│ │ Response │ │ Tools │ │ -│ └──────────────┘ └──────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -## Features Demonstrated - -1. **Multiple DAPs**: Weather tools and Finance tools from separate providers -2. **Caching Strategies**: Different TTL values for different tool sources -3. **Cache Invalidation**: Manually refresh tools when needed -4. **Multi-source Composition**: Combine tools from multiple DAPs in one query - -## Prerequisites - -- Python 3.10+ -- Google AI API key (`GEMINI_API_KEY`) - -## Running the Sample - -### Using the Sample Runner (Recommended) - -```bash -# From the py/ directory -./bin/run_sample dap-demo -``` - -### Manual Execution - -```bash -# Navigate to sample directory -cd py/samples/dap-demo - -# Install dependencies -uv sync - -# Run with Genkit DevUI -./run.sh - -# Or run with genkit start directly -uv run genkit start -- python src/main.py -``` - -## Testing in the DevUI - -1. **Start the sample** using `./run.sh` or the sample runner -2. **Open the DevUI** at http://localhost:4000 -3. **Navigate to Flows** in the left sidebar -4. **Select a flow** (e.g., `weather_assistant`) -5. **Click Run** - default input values are pre-filled -6. **View the result** in the response panel - -### Testing Each Flow - -| Flow | Default Input | Expected Output | -|------|---------------|-----------------| -| `weather_assistant` | `{"city": "Tokyo"}` | Weather information for Tokyo | -| `finance_assistant` | `{"query": "What's the current price of AAPL stock?"}` | List of available finance tools | -| `multi_assistant` | `{"query": "..."}` | List of all tools from both DAPs | -| `refresh_tools_demo` | `{"source": "all"}` | Cache invalidation confirmation | -| `list_dap_tools` | `{"source": "all"}` | List of all available tool names | - -## Available Flows - -### weather_assistant - -Get weather information for a city using the dynamically-provided weather tool. - -**Input**: `WeatherInput` with `city` field (default: "Tokyo") - -### finance_assistant - -Answer finance questions using dynamically-provided finance tools. - -**Input**: `FinanceInput` with `query` field (default: "What's the current price of AAPL stock?") - -### multi_assistant - -Multi-source assistant that combines tools from both Weather and Finance DAPs. - -**Input**: `MultiInput` with `query` field - -### refresh_tools_demo - -Invalidate DAP cache to force fresh tool fetching. - -**Input**: `RefreshInput` with `source` field ("weather", "finance", or "all") - -### list_dap_tools - -List all tools provided by a specific DAP or all DAPs. - -**Input**: `ListToolsInput` with `source` field ("weather", "finance", or "all") - -## DAP Configuration Examples - -### Weather Tools DAP (Short Cache) - -```python -weather_dap = ai.define_dynamic_action_provider( - config=DapConfig( - name='weather-tools', - description='Provides weather-related tools', - cache_config=DapCacheConfig(ttl_millis=5000), # 5 second cache - ), - fn=weather_tools_provider, -) -``` - -### Finance Tools DAP (Long Cache) - -```python -finance_dap = ai.define_dynamic_action_provider( - config=DapConfig( - name='finance-tools', - description='Provides finance and market tools', - cache_config=DapCacheConfig(ttl_millis=60000), # 60 second cache - ), - fn=finance_tools_provider, -) -``` - -## Use Cases - -1. **MCP Integration**: Use DAPs to connect to MCP servers and expose their - tools to Genkit. See the `genkit-plugin-mcp` package for a complete - implementation. - -2. **Plugin Marketplace**: Load tools from an external registry based on - user preferences or subscription level. - -3. **Multi-tenant SaaS**: Provide different tools to different tenants based - on their configuration or tier. - -4. **A/B Testing**: Enable different tool sets for different users to test - effectiveness. - -## Related Resources - -- [Genkit Python SDK Documentation](https://firebase.google.com/docs/genkit/python) -- [MCP Plugin](../../plugins/mcp/) - Full MCP integration using DAP -- [JS DAP Implementation](../../../../js/core/src/dynamic-action-provider.ts) diff --git a/py/samples/dap-demo/pyproject.toml b/py/samples/dap-demo/pyproject.toml deleted file mode 100644 index f6b04188ad..0000000000 --- a/py/samples/dap-demo/pyproject.toml +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright 2026 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" }] -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", - "rich>=13.0.0", -] -description = "Dynamic Action Provider (DAP) demonstration sample" -license = "Apache-2.0" -name = "dap-demo" -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/dap_demo"] - -[tool.hatch.metadata] -allow-direct-references = true diff --git a/py/samples/dap-demo/run.sh b/py/samples/dap-demo/run.sh deleted file mode 100755 index f6fc6677d6..0000000000 --- a/py/samples/dap-demo/run.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2026 Google LLC -# SPDX-License-Identifier: Apache-2.0 - -# Dynamic Action Provider (DAP) Demo -# =================================== -# -# Demonstrates how to use Dynamic Action Providers to provide tools at runtime. -# -# 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 "DAP 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 "Get an API key from: https://makersuite.google.com/app/apikey" - print_help_footer -} - -# Parse arguments -case "${1:-}" in - --help|-h) - print_help - exit 0 - ;; -esac - -# Main execution -print_banner "DAP Demo" "🔌" - -check_env_var "GEMINI_API_KEY" "https://makersuite.google.com/app/apikey" || true - -install_deps - -# Start with hot reloading and auto-open browser -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/dap_demo/__init__.py "$@" diff --git a/py/samples/dap-demo/src/dap_demo/__init__.py b/py/samples/dap-demo/src/dap_demo/__init__.py deleted file mode 100644 index 892f4cb622..0000000000 --- a/py/samples/dap-demo/src/dap_demo/__init__.py +++ /dev/null @@ -1,514 +0,0 @@ -# Copyright 2026 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 - -"""Dynamic Action Provider (DAP) Demo. - -This sample demonstrates how to use Dynamic Action Providers (DAPs) to provide -tools dynamically at runtime. DAPs are useful for integrating external tool -sources like MCP servers, plugin registries, or service meshes. - -What is DAP? (ELI5 - The Toy Box Analogy) ------------------------------------------ - -Imagine you have two ways to get toys: - -**Regular Tools (Static)** - Your Toy Box at Home:: - - 📦 Your Toy Box - ├── 🚗 Car (always there) - ├── 🧸 Teddy Bear (always there) - └── 🎮 Game (always there) - -You know exactly what toys you have. They're always in the same spot. -This is like regular ``@ai.tool()`` - defined once at startup, always available. - -**DAP Tools (Dynamic)** - A Toy Rental Store:: - - 🏪 Toy Rental Store (DAP) - ├── "What toys do you have today?" - ├── Store checks inventory... - └── "Today we have: 🚀 Rocket, 🦖 Dinosaur, 🎸 Guitar!" - -The toys change! You ask the store, they check what's in stock RIGHT NOW, -and give you options. Tomorrow might be different toys! - -Static vs Dynamic Tools:: - - ┌─────────────────────────────────────────────────────────────────┐ - │ WITHOUT DAP │ - │ │ - │ Your App Starts │ - │ │ │ - │ ▼ │ - │ ┌─────────────┐ │ - │ │ Define tool │ @ai.tool("get_weather") │ - │ │ Define tool │ @ai.tool("get_stocks") │ - │ └─────────────┘ │ - │ │ │ - │ ▼ │ - │ Tools are FIXED. Can't add new ones without restarting. │ - └─────────────────────────────────────────────────────────────────┘ - - ┌─────────────────────────────────────────────────────────────────┐ - │ WITH DAP │ - │ │ - │ Your App Starts │ - │ │ │ - │ ▼ │ - │ ┌───────────────────┐ │ - │ │ Register DAP │ "Ask MCP server for tools" │ - │ └───────────────────┘ │ - │ │ │ - │ ▼ │ - │ When you need tools... │ - │ │ │ - │ ▼ │ - │ ┌───────────────────┐ ┌─────────────────────┐ │ - │ │ DAP asks server │ ──► │ MCP Server says: │ │ - │ │ "What tools now?" │ │ "I have 5 tools!" │ │ - │ └───────────────────┘ └─────────────────────┘ │ - │ │ │ - │ ▼ │ - │ Tools could be DIFFERENT each time! 🎉 │ - └─────────────────────────────────────────────────────────────────┘ - -Key Concepts:: - - ┌─────────────────────┬────────────────────────────────────────────────┐ - │ Concept │ ELI5 Explanation │ - ├─────────────────────┼────────────────────────────────────────────────┤ - │ DAP │ A "tool factory" that creates tools on-demand. │ - │ │ Like asking a store what's in stock. │ - ├─────────────────────┼────────────────────────────────────────────────┤ - │ Dynamic Tool │ A tool created at runtime, not at startup. │ - │ │ Like ordering custom pizza vs frozen. │ - ├─────────────────────┼────────────────────────────────────────────────┤ - │ Cache │ Remembers tools to avoid recreating them. │ - │ │ Like a notepad to avoid asking twice. │ - ├─────────────────────┼────────────────────────────────────────────────┤ - │ TTL (Time-To-Live) │ How long cached tools stay fresh. │ - │ │ Like an expiration date on milk. │ - ├─────────────────────┼────────────────────────────────────────────────┤ - │ Invalidation │ Throwing away stale cached tools. │ - │ │ Like clearing your browser cache. │ - └─────────────────────┴────────────────────────────────────────────────┘ - -Why Use DAP? ------------- - -1. **MCP Servers** - Connect to external tool servers that add/remove tools -2. **Plugin Systems** - Users can install new tools without restarting -3. **Multi-tenant** - Different users might have access to different tools -4. **Service Mesh** - Tools discovered from a network of microservices - -Data Flow:: - - User Request - │ - ▼ - ┌─────────────────┐ - │ Flow │ (e.g., weather_assistant) - └────────┬────────┘ - │ - ▼ - ┌─────────────────┐ ┌─────────────────┐ - │ DAP │ ──► │ Tool Cache │ - │ (weather-tools)│ │ (TTL: 5s) │ - └────────┬────────┘ └─────────────────┘ - │ - │ Cache miss? Call provider function - ▼ - ┌─────────────────┐ - │ Tool Provider │ Creates dynamic tools - │ Function │ (get_weather, etc.) - └────────┬────────┘ - │ - ▼ - ┌─────────────────┐ - │ Dynamic Tool │ Used in AI generation - │ Execution │ - └────────┬────────┘ - │ - ▼ - ┌─────────────────┐ - │ Response │ - └─────────────────┘ - -Running the Demo ----------------- - -1. Navigate to the sample directory:: - - cd py/samples/dap-demo - -2. Run with the sample runner:: - - ../../bin/run_sample dap-demo - -3. Or run directly with genkit start:: - - uv run genkit start -- python src/dap_demo/__init__.py - -Testing in the DevUI --------------------- - -1. Open http://localhost:4000 -2. Navigate to Flows -3. Select any flow (e.g., 'weather_assistant') -4. The default input values are pre-filled - just click Run - -Available Flows ---------------- - -- **weather_assistant**: Get weather for a city using dynamic tools -- **finance_assistant**: Answer finance questions with multiple tools -- **multi_assistant**: Combine tools from multiple DAPs -- **refresh_tools_demo**: Demonstrate cache invalidation -- **list_dap_tools**: List all available tools from DAPs -""" - -import asyncio -import random -import re -from typing import Any - -from pydantic import BaseModel, Field -from rich.traceback import install as install_rich_traceback - -from genkit.ai import Genkit -from genkit.blocks.dap import DapCacheConfig, DapConfig, DapValue -from genkit.core.action import Action -from genkit.plugins.google_genai import GoogleAI - -install_rich_traceback(show_locals=True, width=120, extra_lines=3) - -ai = Genkit( - plugins=[GoogleAI()], -) - - -class WeatherInput(BaseModel): - """Input for weather assistant flow.""" - - city: str = Field(default='Tokyo', description='City name to get weather for') - - -class FinanceInput(BaseModel): - """Input for finance assistant flow.""" - - query: str = Field(default="What's the current price of AAPL stock?", description='Finance question to answer') - - -class MultiInput(BaseModel): - """Input for multi-source assistant flow.""" - - query: str = Field( - default="What's the weather in London and how is the EUR/USD exchange rate?", - description='Question that may require multiple tool sources', - ) - - -class RefreshInput(BaseModel): - """Input for cache refresh demo flow.""" - - source: str = Field(default='all', description="Which DAP to refresh: 'weather', 'finance', or 'all'") - - -class ListToolsInput(BaseModel): - """Input for list tools flow.""" - - source: str = Field(default='all', description="Which DAP to list: 'weather', 'finance', or 'all'") - - -class CurrencyConversion(BaseModel): - """Input for currency conversion tool.""" - - amount: float = Field(default=100.0, description='Amount to convert') - from_currency: str = Field(default='USD', description='Source currency code') - to_currency: str = Field(default='EUR', description='Target currency code') - - -async def fetch_weather_data(city: str) -> dict[str, Any]: - """Simulate fetching weather from an external API.""" - await asyncio.sleep(0.1) # Simulate network delay - return { - 'city': city, - 'temperature': random.randint(15, 35), - 'conditions': random.choice(['Sunny', 'Cloudy', 'Rainy', 'Windy']), - 'humidity': random.randint(30, 80), - } - - -async def fetch_stock_price(symbol: str) -> dict[str, Any]: - """Simulate fetching stock price from an external API.""" - await asyncio.sleep(0.1) # Simulate network delay - return { - 'symbol': symbol.upper(), - 'price': round(random.uniform(50, 500), 2), - 'change': round(random.uniform(-5, 5), 2), - 'volume': random.randint(1000000, 10000000), - } - - -async def weather_tools_provider() -> DapValue: - """DAP function that provides weather-related tools. - - In a real scenario, this could connect to an MCP server, load tools from - a plugin registry, or discover tools from a service mesh. - - Uses ai.dynamic_tool() to create unregistered tools that are returned - directly to consumers without being in the global registry. - """ - - async def get_weather_impl(city: str) -> str: - """Get current weather for a city.""" - data = await fetch_weather_data(city) - return ( - f'Weather in {data["city"]}: {data["temperature"]}°C, {data["conditions"]}, Humidity: {data["humidity"]}%' - ) - - get_weather = ai.dynamic_tool( - name='get_weather', - fn=get_weather_impl, - description='Get current weather for a city', - ) - - return {'tool': [get_weather]} - - -async def finance_tools_provider() -> DapValue: - """DAP function that provides finance-related tools. - - This provider has a longer cache TTL because the available tools change - less frequently than weather tools. - """ - - async def get_stock_price_impl(symbol: str) -> str: - """Get current stock price by symbol.""" - data = await fetch_stock_price(symbol) - change_str = f'+{data["change"]}' if data['change'] > 0 else str(data['change']) - return f'{data["symbol"]}: ${data["price"]} ({change_str}%), Volume: {data["volume"]:,}' - - async def convert_currency_impl(input: CurrencyConversion) -> str: - """Convert between currencies.""" - rates = {'USD': 1.0, 'EUR': 0.85, 'GBP': 0.73, 'JPY': 110.0} - from_rate = rates.get(input.from_currency.upper(), 1.0) - to_rate = rates.get(input.to_currency.upper(), 1.0) - converted = input.amount / from_rate * to_rate - return f'{input.amount} {input.from_currency.upper()} = {converted:.2f} {input.to_currency.upper()}' - - get_stock_price = ai.dynamic_tool( - name='get_stock_price', - fn=get_stock_price_impl, - description='Get current stock price by symbol', - ) - - convert_currency = ai.dynamic_tool( - name='convert_currency', - fn=convert_currency_impl, - description='Convert between currencies', - ) - - return {'tool': [get_stock_price, convert_currency]} - - -weather_dap = ai.define_dynamic_action_provider( - config=DapConfig( - name='weather-tools', - description='Provides weather-related tools', - cache_config=DapCacheConfig(ttl_millis=5000), - ), - fn=weather_tools_provider, -) - -finance_dap = ai.define_dynamic_action_provider( - config=DapConfig( - name='finance-tools', - description='Provides finance and market tools', - cache_config=DapCacheConfig(ttl_millis=60000), - ), - fn=finance_tools_provider, -) - - -@ai.flow(description='Weather assistant using dynamically-provided tools') -async def weather_assistant(input: WeatherInput) -> str: - """Get weather information for a city using dynamic tools. - - The weather tool is provided by the weather-tools DAP, which could be - sourced from an MCP server, plugin registry, or other external system. - """ - weather_tool = await weather_dap.get_action('tool', 'get_weather') - - if not weather_tool: - return f'Weather service unavailable. Cannot get weather for {input.city}.' - - result = await weather_tool.arun(input.city) - return str(result.response) - - -@ai.flow(description='Finance assistant using dynamically-provided tools') -async def finance_assistant(input: FinanceInput) -> str: - """Answer finance questions using dynamic tools. - - The finance tools are provided by the finance-tools DAP with a longer - cache TTL since the available tools change less frequently. - - This flow demonstrates using a model to answer queries with dynamic tools. - """ - cache_result = await finance_dap._cache.get_or_fetch() # noqa: SLF001 - accessing internal cache for demo - tools = cache_result.get('tool', []) - - if not tools: - return 'Finance service unavailable.' - - # Use a model to answer the query using the dynamic tools - # Note: Dynamic tools are not in the global registry, so we invoke them directly. - # For stock queries, use the get_stock_price tool - get_stock_price = next((t for t in tools if t.name == 'get_stock_price'), None) - if get_stock_price and 'stock' in input.query.lower(): - # Extract stock symbol from query (clean punctuation, search from end) - cleaned_words = (re.sub(r'[^A-Z0-9]', '', w) for w in reversed(input.query.upper().split())) - symbol = next((w for w in cleaned_words if w and 1 <= len(w) <= 5), 'AAPL') - result = await get_stock_price.arun(symbol) - return str(result.response) - - # For currency queries, use the convert_currency tool - convert_currency = next((t for t in tools if t.name == 'convert_currency'), None) - if convert_currency and any(word in input.query.lower() for word in ['convert', 'exchange', 'currency']): - result = await convert_currency.arun(CurrencyConversion()) - return str(result.response) - - tool_names = [t.name for t in tools] - return f'Available finance tools: {", ".join(tool_names)}. Try asking about stocks or currency conversion.' - - -@ai.flow(description='Multi-source assistant combining tools from multiple DAPs') -async def multi_assistant(input: MultiInput) -> str: - """Assistant that can use tools from multiple DAPs. - - This demonstrates how DAPs can be composed to provide tools from - multiple sources (weather service + finance APIs) in a single query. - - Uses asyncio.gather for concurrent fetching. - """ - all_tools: list[Action[Any, Any]] = [] - - # Fetch from both DAPs concurrently for efficiency - weather_cache, finance_cache = await asyncio.gather( - weather_dap._cache.get_or_fetch(), # noqa: SLF001 - finance_dap._cache.get_or_fetch(), # noqa: SLF001 - ) - all_tools.extend(weather_cache.get('tool', [])) - all_tools.extend(finance_cache.get('tool', [])) - - if not all_tools: - return 'No tools available.' - - # Demonstrate composing tools from multiple sources - # Collect results from all matching tools - results: list[str] = [] - - # For weather queries, use the weather tool - get_weather = next((t for t in all_tools if t.name == 'get_weather'), None) - if get_weather and 'weather' in input.query.lower(): - # Extract city name (simple heuristic - use first capitalized word after 'in') - match = re.search(r'\bin\s+(\w+)', input.query, re.IGNORECASE) - city = match.group(1) if match else 'London' - result = await get_weather.arun(city) - results.append(str(result.response)) - - # For currency/exchange queries, use convert_currency tool - convert_currency = next((t for t in all_tools if t.name == 'convert_currency'), None) - if convert_currency and any(word in input.query.lower() for word in ['eur', 'usd', 'exchange', 'currency', 'rate']): - result = await convert_currency.arun(CurrencyConversion(from_currency='EUR', to_currency='USD')) - results.append(str(result.response)) - - # For stock queries, use get_stock_price tool - get_stock_price = next((t for t in all_tools if t.name == 'get_stock_price'), None) - if get_stock_price and any(word in input.query.lower() for word in ['stock', 'price', 'aapl']): - result = await get_stock_price.arun('AAPL') - results.append(str(result.response)) - - if results: - return ' | '.join(results) - - tool_names = [t.name for t in all_tools] - return f'Available tools: {", ".join(tool_names)}. Try asking about weather, stocks, or currency.' - - -@ai.flow(description='Demonstrate DAP cache invalidation') -async def refresh_tools_demo(input: RefreshInput) -> str: - """Invalidate and refresh dynamic tools. - - In a real scenario, you might invalidate the cache when: - - An MCP server restarts - - A plugin is added/removed - - Configuration changes - """ - if input.source == 'weather': - weather_dap.invalidate_cache() - return 'Weather tools cache invalidated. Next request will fetch fresh tools.' - elif input.source == 'finance': - finance_dap.invalidate_cache() - return 'Finance tools cache invalidated. Next request will fetch fresh tools.' - elif input.source == 'all': - weather_dap.invalidate_cache() - finance_dap.invalidate_cache() - return 'All DAP caches invalidated. Next requests will fetch fresh tools.' - else: - return f"Unknown source: {input.source}. Use 'weather', 'finance', or 'all'." - - -@ai.flow(description='List all tools available from DAPs') -async def list_dap_tools(input: ListToolsInput) -> str: - """List all tools provided by a specific DAP or all DAPs. - - This demonstrates retrieving tools from DAP cache. - """ - if input.source == 'weather': - cache = await weather_dap._cache.get_or_fetch() # noqa: SLF001 - tools = cache.get('tool', []) - return f'Weather tools: {[t.name for t in tools]}' - elif input.source == 'finance': - cache = await finance_dap._cache.get_or_fetch() # noqa: SLF001 - tools = cache.get('tool', []) - return f'Finance tools: {[t.name for t in tools]}' - elif input.source == 'all': - # Fetch from both DAPs concurrently for efficiency - weather_cache, finance_cache = await asyncio.gather( - weather_dap._cache.get_or_fetch(), # noqa: SLF001 - finance_dap._cache.get_or_fetch(), # noqa: SLF001 - ) - weather_tools = weather_cache.get('tool', []) - finance_tools = finance_cache.get('tool', []) - all_names = [t.name for t in weather_tools] + [t.name for t in finance_tools] - return f'All available tools: {all_names}' - else: - return f"Unknown source: {input.source}. Use 'weather', 'finance', or 'all'." - - -async def main() -> None: - """Keep the server running for the DevUI. - - When running with 'genkit start', this keeps the process alive so flows - can be tested through the DevUI at http://localhost:4000. - """ - await asyncio.Event().wait() - - -if __name__ == '__main__': - ai.run_main(main()) diff --git a/py/uv.lock b/py/uv.lock index 6b35075e71..0d84d2ae2b 100644 --- a/py/uv.lock +++ b/py/uv.lock @@ -15,7 +15,6 @@ members = [ "anthropic-hello", "cloudflare-workers-ai-hello", "compat-oai-hello", - "dap-demo", "deepseek-hello", "dev-local-vectorstore-hello", "evaluator-demo", @@ -1320,32 +1319,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/1b/534ad8a5e0f9470522811a8e5a9bc5d328fb7738ba29faf357467a4ef6d0/cyclonedx_python_lib-11.6.0-py3-none-any.whl", hash = "sha256:94f4aae97db42a452134dafdddcfab9745324198201c4777ed131e64c8380759", size = 511157, upload-time = "2025-12-02T12:28:44.158Z" }, ] -[[package]] -name = "dap-demo" -version = "0.1.0" -source = { editable = "samples/dap-demo" } -dependencies = [ - { name = "genkit" }, - { name = "genkit-plugin-google-genai" }, - { name = "pydantic" }, - { name = "rich" }, -] - -[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 = "rich", specifier = ">=13.0.0" }, - { name = "watchdog", marker = "extra == 'dev'", specifier = ">=6.0.0" }, -] -provides-extras = ["dev"] - [[package]] name = "datamodel-code-generator" version = "0.53.0"