From 7037cdd508df237f38a653bb1e02c0d76cf105b8 Mon Sep 17 00:00:00 2001 From: Mengqin Shen Date: Tue, 16 Dec 2025 09:15:58 -0800 Subject: [PATCH 1/5] feat(py):addin directory and file prompt loading in genkit matching JS SDK --- py/packages/genkit/src/genkit/ai/_registry.py | 19 +- .../genkit/src/genkit/blocks/prompt.py | 882 +++++++++++++++++- .../genkit/tests/genkit/blocks/prompt_test.py | 110 +++ py/uv.lock | 123 ++- 4 files changed, 1114 insertions(+), 20 deletions(-) diff --git a/py/packages/genkit/src/genkit/ai/_registry.py b/py/packages/genkit/src/genkit/ai/_registry.py index bc98b0ab09..1ef369f71e 100644 --- a/py/packages/genkit/src/genkit/ai/_registry.py +++ b/py/packages/genkit/src/genkit/ai/_registry.py @@ -286,15 +286,7 @@ def define_indexer( metadata: dict[str, Any] | None = None, description: str | None = None, ) -> Callable[[Callable], Callable]: - """Define an indexer action. - Args: - name: Name of the indexer. - fn: Function implementing the indexer behavior. - config_schema: Optional schema for indexer configuration. - metadata: Optional metadata for the indexer. - description: Optional description for the indexer. - """ indexer_meta = metadata if metadata else {} if 'indexer' not in indexer_meta: indexer_meta['indexer'] = {} @@ -607,6 +599,17 @@ def define_prompt( use=use, ) + async def prompt( + self, + name: str, + variant: str | None = None, + ): + + return await lookup_prompt( + registry=self.registry, + name=name, + variant=variant, + ) class FlowWrapper: """A wapper for flow functions to add `stream` method.""" diff --git a/py/packages/genkit/src/genkit/blocks/prompt.py b/py/packages/genkit/src/genkit/blocks/prompt.py index 4589de0d1c..1819829f5f 100644 --- a/py/packages/genkit/src/genkit/blocks/prompt.py +++ b/py/packages/genkit/src/genkit/blocks/prompt.py @@ -22,8 +22,12 @@ generation and management across different parts of the application. """ +import structlog +import os +from pathlib import Path + from asyncio import Future -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Callable from typing import Any from dotpromptz.typing import ( @@ -45,15 +49,19 @@ GenerateResponseWrapper, ModelMiddleware, ) -from genkit.core.action import ActionRunContext +from genkit.core.action import Action, ActionRunContext, create_action_key +from genkit.core.action.types import ActionKind +from genkit.core.error import GenkitError from genkit.core.registry import Registry from genkit.core.schema import to_json_schema from genkit.core.typing import ( DocumentData, GenerateActionOptions, GenerateActionOutputConfig, + GenerateRequest, GenerationCommonConfig, Message, + OutputConfig, Part, Resume, Role, @@ -121,6 +129,9 @@ def __init__( tools: list[str] | None = None, tool_choice: ToolChoice | None = None, use: list[ModelMiddleware] | None = None, + # _name: str | None = None, # Internal: prompt name for action lookup + # _ns: str | None = None, # Internal: namespace for action lookup + # _prompt_action: Action | None = None, # Internal: reference to PROMPT action # TODO: # docs: list[Document]): ): @@ -169,6 +180,10 @@ def __init__( self._tool_choice = tool_choice self._use = use self._cache_prompt = PromptCache() + # Store name/ns for action lookup (used by as_tool()) + # self._name = _name + # self._ns = _ns + # self._prompt_action = _prompt_action async def __call__( self, @@ -318,6 +333,34 @@ async def render( resume=resume, ) + async def as_tool(self) -> Action: + """Expose this prompt as a tool. + + Returns the PROMPT action, which can be used as a tool. + Similar to JS asTool(). + """ + # If we have a direct reference to the action, use it + if self._prompt_action is not None: + return self._prompt_action + + # Otherwise, try to look it up using name/variant/ns + if self._name is None: + raise GenkitError( + status='FAILED_PRECONDITION', + message='Prompt name not available. This prompt was not created via define_prompt_async() or load_prompt().', + ) + + lookup_key = registry_lookup_key(self._name, self._variant, self._ns) + + action = self._registry.lookup_action_by_key(lookup_key) + + if action is None or action.kind != ActionKind.PROMPT: + raise GenkitError( + status='NOT_FOUND', + message=f'PROMPT action not found for prompt "{self._name}"', + ) + + return action def define_prompt( registry: Registry, @@ -466,6 +509,45 @@ async def to_generate_action_options(registry: Registry, options: PromptConfig) resume=resume, ) +async def to_generate_request( + registry: Registry, + options: GenerateActionOptions +) -> GenerateRequest: + """Convert GenerateActionOptions to GenerateRequest. + + Similar to JS toGenerateRequest(). Resolves tools and converts to GenerateRequest format. + """ + + tools: list[Action] = [] + if options.tools: + for tool_name in options.tools: + tool_action = registry.lookup_action(ActionKind.TOOL, tool_name) + if tool_action is None: + raise Exception(f'Unable to resolve tool {tool_name}') + tools.append(tool_action) + + tool_defs = [to_tool_definition(tool) for tool in tools] if tools else [] + + if not options.messages: + from genkit.core.error import GenkitError + raise GenkitError( + status='INVALID_ARGUMENT', + message='at least one message is required in generate request', + ) + + return GenerateRequest( + messages=options.messages, + config=options.config if options.config is not None else {}, + docs=options.docs, + tools=tool_defs, + tool_choice=options.tool_choice, + output=OutputConfig( + content_type=options.output.content_type if options.output else None, + format=options.output.format if options.output else None, + schema_=options.output.json_schema if options.output else None, + constrained=options.output.constrained if options.output else None, + ), + ) def _normalize_prompt_arg( prompt: str | Part | list[Part] | None, @@ -523,7 +605,7 @@ async def render_system_prompt( prompt_cache.system = await registry.dotprompt.compile(options.system) if options.metadata: - context = {**context, 'state': options.metadata.get('state')} + context = {**(context or {}), 'state': options.metadata.get('state')} return Message( role=Role.SYSTEM, @@ -615,7 +697,7 @@ async def render_message_prompt( prompt_cache.messages = await registry.dotprompt.compile(options.messages) if options.metadata: - context = {**context, 'state': options.metadata.get('state')} + context = {**(context or {}), 'state': options.metadata.get('state')} messages_ = None if isinstance(options.messages, list): @@ -666,7 +748,7 @@ async def render_user_prompt( prompt_cache.user_prompt = await registry.dotprompt.compile(options.prompt) if options.metadata: - context = {**context, 'state': options.metadata.get('state')} + context = {**(context or {}), 'state': options.metadata.get('state')} return Message( role=Role.USER, @@ -679,3 +761,793 @@ async def render_user_prompt( ) return Message(role=Role.USER, content=_normalize_prompt_arg(options.prompt)) + + +logger = structlog.get_logger(__name__) + + +def registry_definition_key(name: str, variant: str | None = None, ns: str | None = None) -> str: + """Generate a registry definition key for a prompt. + + Format: "ns/name.variant" where ns and variant are optional. + + Args: + name: The prompt name. + variant: Optional variant name. + ns: Optional namespace. + + Returns: + Registry key string. + """ + parts = [] + if ns: + parts.append(ns) + parts.append(name) + if variant: + parts[-1] = f"{parts[-1]}.{variant}" + return "/".join(parts) + + +def registry_lookup_key(name: str, variant: str | None = None, ns: str | None = None) -> str: + """Generate a registry lookup key for a prompt. + + Args: + name: The prompt name. + variant: Optional variant name. + ns: Optional namespace. + + Returns: + Registry lookup key string. + """ + return f"/prompt/{registry_definition_key(name, variant, ns)}" + + +def define_partial(registry: Registry, name: str, source: str) -> None: + """Define a partial template in the registry. + + Partials are reusable template fragments that can be included in other prompts. + In JS, files starting with `_` are treated as partials. + + Args: + registry: The registry to register the partial in. + name: The name of the partial. + source: The template source code. + """ + registry.dotprompt.define_partial(name, source) + logger.debug(f'Registered Dotprompt partial "{name}"') + + +def define_helper(registry: Registry, name: str, fn: Callable) -> None: + """Define a Handlebars helper function in the registry. + + Args: + registry: The registry to register the helper in. + name: The name of the helper function. + fn: The helper function to register. + """ + registry.dotprompt.define_helper(name, fn) + logger.debug(f'Registered Dotprompt helper "{name}"') + + +def load_prompt( + registry: Registry, + path: Path, + filename: str, + prefix: str = '', + ns: str = 'dotprompt' +) -> None: + """Load a single prompt file and register it in the registry. + + This function loads a .prompt file, parses it, and registers it as a lazy-loaded + prompt that will only be fully loaded when first accessed. + + Args: + registry: The registry to register the prompt in. + path: Base path to the prompts directory. + filename: Name of the prompt file (e.g., "myPrompt.prompt" or "myPrompt.variant.prompt"). + prefix: Subdirectory prefix (for namespacing). + ns: Namespace for the prompt. + """ + # Extract name and variant from filename + # Matches JS behavior: prefix is included in name before variant extraction + # "myPrompt.prompt" -> name="myPrompt", variant=None + # "myPrompt.variant.prompt" -> name="myPrompt", variant="variant" + # "subdir/myPrompt.prompt" -> name="subdir/myPrompt", variant=None + base_name = filename[:-7] # Remove ".prompt" extension + # Include prefix in name (matches JS: `${prefix ?? ''}${basename(filename, '.prompt')}`) + if prefix: + name = f"{prefix}{base_name}" + else: + name = base_name + variant: str | None = None + + # Extract variant (matches JS: only takes parts[1], not all remaining parts) + if '.' in name: + parts = name.split('.') + name = parts[0] + variant = parts[1] # Only first part after split (matches JS behavior) + + # Build full file path + # Match JS: join(path, prefix ?? '', filename) + # prefix may have trailing slash, so we need to handle it + if prefix: + # Strip trailing slash for path construction (pathlib handles it) + prefix_clean = prefix.rstrip('/') + file_path = path / prefix_clean / filename + else: + file_path = path / filename + + # Read the prompt file + with open(file_path, 'r', encoding='utf-8') as f: + source = f.read() + + # Parse the prompt + parsed_prompt = registry.dotprompt.parse(source) + + # Generate registry key + registry_key = registry_definition_key(name, variant, ns) + + # Create a lazy-loaded prompt definition + # The prompt will only be fully loaded when first accessed + async def load_prompt_metadata(): + """Lazy loader for prompt metadata.""" + prompt_metadata = await registry.dotprompt.render_metadata(parsed_prompt) + + # Convert Pydantic model to dict if needed + if hasattr(prompt_metadata, 'model_dump'): + prompt_metadata_dict = prompt_metadata.model_dump() + elif hasattr(prompt_metadata, 'dict'): + prompt_metadata_dict = prompt_metadata.dict() + else: + # Already a dict + prompt_metadata_dict = prompt_metadata + + if variant: + prompt_metadata_dict['variant'] = variant + + # Clean up null descriptions (matches JS behavior) + output = prompt_metadata_dict.get('output') + if output and isinstance(output, dict): + schema = output.get('schema') + if schema and isinstance(schema, dict) and schema.get('description') is None: + schema.pop('description', None) + + input_schema = prompt_metadata_dict.get('input') + if input_schema and isinstance(input_schema, dict): + schema = input_schema.get('schema') + if schema and isinstance(schema, dict) and schema.get('description') is None: + schema.pop('description', None) + + # Build metadata structure + metadata = { + **prompt_metadata_dict.get('metadata', {}), + 'type': 'prompt', + 'prompt': { + **prompt_metadata_dict, + 'template': parsed_prompt.template, + }, + } + + raw = prompt_metadata_dict.get('raw') + if raw and isinstance(raw, dict) and raw.get('metadata'): + metadata['metadata'] = {**raw['metadata']} + + output = prompt_metadata_dict.get('output') + input_schema = prompt_metadata_dict.get('input') + raw = prompt_metadata_dict.get('raw') + + return { + 'name': registry_key, + 'model': prompt_metadata_dict.get('model'), + 'config': prompt_metadata_dict.get('config'), + 'tools': prompt_metadata_dict.get('tools'), + 'description': prompt_metadata_dict.get('description'), + 'output': { + 'jsonSchema': output.get('schema') if output and isinstance(output, dict) else None, + 'format': output.get('format') if output and isinstance(output, dict) else None, + }, + 'input': { + 'jsonSchema': input_schema.get('schema') if input_schema and isinstance(input_schema, dict) else None, + }, + 'metadata': metadata, + 'maxTurns': raw.get('maxTurns') if raw and isinstance(raw, dict) else None, + 'toolChoice': raw.get('toolChoice') if raw and isinstance(raw, dict) else None, + 'returnToolRequests': raw.get('returnToolRequests') if raw and isinstance(raw, dict) else None, + 'messages': parsed_prompt.template, + } + + # Create a factory function that will create the ExecutablePrompt when accessed + # Store metadata in a closure to avoid global state + async def create_prompt_from_file(): + """Factory function to create ExecutablePrompt from file metadata.""" + metadata = await load_prompt_metadata() + + # Create ExecutablePrompt from metadata + # Pass name/ns so as_tool() can look up the action + executable_prompt = ExecutablePrompt( + registry=registry, + variant=metadata.get('variant'), + model=metadata.get('model'), + config=metadata.get('config'), + description=metadata.get('description'), + input_schema=metadata.get('input', {}).get('jsonSchema'), + output_schema=metadata.get('output', {}).get('jsonSchema'), + output_format=metadata.get('output', {}).get('format'), + messages=metadata.get('messages'), + max_turns=metadata.get('maxTurns'), + tool_choice=metadata.get('toolChoice'), + return_tool_requests=metadata.get('returnToolRequests'), + metadata=metadata.get('metadata'), + tools=metadata.get('tools'), + # _name=name, # Store name for action lookup + # _ns=ns, # Store namespace for action lookup + ) + + # Store reference to PROMPT action on the ExecutablePrompt + # Actions are already registered at this point (lazy loading happens after registration) + lookup_key = registry_lookup_key(name, variant, ns) + prompt_action = registry.lookup_action_by_key(lookup_key) + if prompt_action and prompt_action.kind == ActionKind.PROMPT: + executable_prompt._prompt_action = prompt_action + # Also store ExecutablePrompt reference on the action (matches JS __executablePrompt) + prompt_action._executable_prompt = executable_prompt + + return executable_prompt + + # Store the async factory in a way that can be accessed later + # We'll store it in the action metadata + action_metadata = { + 'type': 'prompt', + 'lazy': True, + 'source': 'file', + '_async_factory': create_prompt_from_file, + } + + # Create two separate action functions to match JavaScript structure: + # 1. PROMPT action - returns GenerateRequest (for rendering prompts) + # 2. EXECUTABLE_PROMPT action - returns GenerateActionOptions (for executing prompts) + + async def prompt_action_fn(input: Any = None) -> GenerateRequest: + """PROMPT action function - renders prompt and returns GenerateRequest. + + This matches the JavaScript behavior where the PROMPT action's fn + returns GenerateRequest by calling toGenerateRequest(). + """ + # Load the prompt (lazy loading) + prompt = await create_prompt_from_file() + + # Render the prompt with input to get GenerateActionOptions + options = await prompt.render(input=input) + + # Convert GenerateActionOptions to GenerateRequest + return await to_generate_request(registry, options) + + async def executable_prompt_action_fn(input: Any = None) -> GenerateActionOptions: + """EXECUTABLE_PROMPT action function - renders prompt and returns GenerateActionOptions. + + This matches the JavaScript behavior where the EXECUTABLE_PROMPT action's fn + returns GenerateActionOptions by calling toGenerateActionOptions(). + """ + # Load the prompt (lazy loading) + prompt = await create_prompt_from_file() + + # Render the prompt with input to get GenerateActionOptions + return await prompt.render(input=input) + + # Register the PROMPT action + # Use registry_definition_key for the action name (not registry_lookup_key) + # The action name should be just the definition key (e.g., "dotprompt/testPrompt"), + # not the full lookup key (e.g., "/prompt/dotprompt/testPrompt") + action_name = registry_definition_key(name, variant, ns) + prompt_action = registry.register_action( + kind=ActionKind.PROMPT, + name=action_name, + fn=prompt_action_fn, + metadata=action_metadata, + ) + + # Register the EXECUTABLE_PROMPT action + executable_prompt_action = registry.register_action( + kind=ActionKind.EXECUTABLE_PROMPT, + name=action_name, + fn=executable_prompt_action_fn, + metadata=action_metadata, + ) + + # Store the factory function on both actions for easy access + prompt_action._async_factory = create_prompt_from_file + executable_prompt_action._async_factory = create_prompt_from_file + + # Store ExecutablePrompt reference on actions (matches JS __executablePrompt pattern) + # This will be set when the prompt is first accessed (lazy loading) + # We'll update it in create_prompt_from_file after the prompt is created + + logger.debug(f'Registered prompt "{registry_key}" from "{file_path}"') + + +def load_prompt_folder_recursively( + registry: Registry, + dir_path: Path, + ns: str, + sub_dir: str = '' +) -> None: + """Recursively load all prompt files from a directory. + + Args: + registry: The registry to register prompts in. + dir_path: Base path to the prompts directory. + ns: Namespace for prompts. + sub_dir: Current subdirectory being processed (for recursion). + """ + full_path = dir_path / sub_dir if sub_dir else dir_path + + if not full_path.exists() or not full_path.is_dir(): + return + + # Iterate through directory entries + try: + for entry in os.scandir(full_path): + if entry.is_file() and entry.name.endswith('.prompt'): + if entry.name.startswith('_'): + # This is a partial + partial_name = entry.name[1:-7] # Remove "_" prefix and ".prompt" suffix + with open(entry.path, 'r', encoding='utf-8') as f: + source = f.read() + define_partial(registry, partial_name, source) + logger.debug(f'Registered Dotprompt partial "{partial_name}" from "{entry.path}"') + else: + # This is a regular prompt + # Match JS: subDir ? `${subDir}/` : '' + prefix_with_slash = f"{sub_dir}/" if sub_dir else '' + load_prompt( + registry, + dir_path, + entry.name, + prefix_with_slash, + ns + ) + elif entry.is_directory(): + # Recursively process subdirectories + new_sub_dir = os.path.join(sub_dir, entry.name) if sub_dir else entry.name + load_prompt_folder_recursively(registry, dir_path, ns, new_sub_dir) + except PermissionError: + logger.warning(f'Permission denied accessing directory: {full_path}') + except Exception as e: + logger.error(f'Error loading prompts from {full_path}: {e}') + + +def load_prompt_folder( + registry: Registry, + dir_path: str | Path = './prompts', + ns: str = 'dotprompt' +) -> None: + """Load all prompt files from a directory. + + This is the main entry point for loading prompts from a directory. + It recursively processes all `.prompt` files and registers them. + + Args: + registry: The registry to register prompts in. + dir_path: Path to the prompts directory. Defaults to './prompts'. + ns: Namespace for prompts. Defaults to 'dotprompt'. + """ + path = Path(dir_path).resolve() + + if not path.exists(): + logger.warning(f'Prompt directory does not exist: {path}') + return + + if not path.is_dir(): + logger.warning(f'Prompt path is not a directory: {path}') + return + + load_prompt_folder_recursively(registry, path, ns, '') + logger.info(f'Loaded prompts from directory: {path}') + + +async def lookup_prompt( + registry: Registry, + name: str, + variant: str | None = None +) -> ExecutablePrompt: + """Look up a prompt from the registry. + + This matches the JavaScript lookupPrompt function behavior. + + Args: + registry: The registry to look up the prompt from. + name: The name of the prompt. + variant: Optional variant name. + + Returns: + An ExecutablePrompt instance. + + Raises: + GenkitError: If the prompt is not found. + """ + from genkit.core.error import GenkitError + + # Match JS: registryLookupKey(name, variant) - no ns parameter + # Try without namespace first (for programmatic prompts) + # Use create_action_key to build the full key: "/prompt/" + definition_key = registry_definition_key(name, variant, None) + lookup_key = create_action_key(ActionKind.PROMPT, definition_key) + action = registry.lookup_action_by_key(lookup_key) + + # If not found and no namespace was specified, try with default 'dotprompt' namespace + # (for file-based prompts) + if not action: + definition_key = registry_definition_key(name, variant, 'dotprompt') + lookup_key = create_action_key(ActionKind.PROMPT, definition_key) + action = registry.lookup_action_by_key(lookup_key) + + if action: + # Match JS: (registryPrompt as PromptAction).__executablePrompt + # First check if we've stored the ExecutablePrompt directly (matches JS pattern) + if hasattr(action, '_executable_prompt') and action._executable_prompt is not None: + return action._executable_prompt + elif hasattr(action, '_async_factory'): + # Otherwise, create it from the factory (lazy loading) + # This will also set _executable_prompt on the action for future lookups + executable_prompt = await action._async_factory() + # Store it on the action for future lookups (if not already stored) + if not hasattr(action, '_executable_prompt') or action._executable_prompt is None: + action._executable_prompt = executable_prompt + return executable_prompt + else: + # Fallback: try to get from metadata + factory = action.metadata.get('_async_factory') + if factory: + executable_prompt = await factory() + # Store it on the action for future lookups + if not hasattr(action, '_executable_prompt') or action._executable_prompt is None: + action._executable_prompt = executable_prompt + return executable_prompt + # Last resort: this shouldn't happen if prompts are loaded correctly + raise GenkitError( + status='INTERNAL', + message=f'Prompt action found but no ExecutablePrompt available for {name}', + ) + + # Match JS: throw GenkitError with status 'NOT_FOUND' + variant_str = f" (variant {variant})" if variant else "" + raise GenkitError( + status='NOT_FOUND', + message=f'Prompt {name}{variant_str} not found', + ) + + +async def prompt( + registry: Registry, + name: str, + variant: str | None = None, + dir: str | Path | None = None # Accepted but not used, matching JS behavior +) -> ExecutablePrompt: + """Look up a prompt by name and optional variant. + + This matches the JavaScript prompt() function behavior. + + Can look up prompts that were: + 1. Defined programmatically using define_prompt() + 2. Loaded from .prompt files using load_prompt_folder() + + Args: + registry: The registry to look up the prompt from. + name: The name of the prompt. + variant: Optional variant name. + dir: Optional directory parameter (accepted for compatibility but not used). + + Returns: + An ExecutablePrompt instance. + + Raises: + GenkitError: If the prompt is not found. + """ + # Match JS: return await lookupPrompt(registry, name, options?.variant) + # The dir parameter is accepted in JS but not used in the prompt function + return await lookup_prompt(registry, name, variant) +# logger = structlog.get_logger(__name__) +# +# def registry_definition_key(name: str, variant: str | None = None, ns: str | None = None) -> str: +# """ +# Format: "ns/name.variant" where ns and variant are optional. +# """ +# parts = [] +# if ns: +# parts.append(ns) +# parts.append(name) +# if variant: +# parts[-1] = f"{parts[-1]}.{variant}" +# return "/".join(parts) +# +# +# def registry_lookup_key(name: str, variant: str | None = None, ns: str | None = None) -> str: +# +# return f"/prompt/{registry_definition_key(name, variant, ns)}" +# +# +# def define_partial(registry: Registry, name: str, source: str) -> None: +# +# registry.dotprompt.define_partial(name, source) +# logger.debug(f'Registered Dotprompt partial "{name}"') +# +# +# def define_helper(registry: Registry, name: str, fn: Callable) -> None: +# +# registry.dotprompt.define_helper(name, fn) +# logger.debug(f'Registered Dotprompt helper "{name}"') +# +# +# def load_prompt( +# registry: Registry, +# path: str, +# filename: str, +# prefix: str = '', +# ns: str = 'dotprompt' +# ) -> None: +# +# # Extract name and variant from filename +# # "myPrompt.prompt" -> name="myPrompt", variant=None +# # "myPrompt.variant.prompt" -> name="myPrompt", variant="variant" +# # "subdir/myPrompt.prompt" -> name="subdir/myPrompt", variant=None +# base_name = filename[:-7] +# if prefix: +# name = f"{prefix}{base_name}" +# else: +# name = base_name +# +# variant: str | None = None +# +# if '.' in name: +# parts = base_name.split('.') +# name = parts[0] +# variant = parts[1] +# +# if prefix: +# prefix_clean = prefix.rstrip('/') +# file_path = path/prefix_clean/filename +# else: +# file_path = path/filename +# +# with open(file_path, 'r', encoding='utf-8') as f: +# source = f.read() +# +# parsed_prompt = registry.dotprompt.parse(source) +# registry_key = registry_definition_key(name, variant, ns) +# +# # Create a lazy-loaded prompt definition,the prompt will only be fully loaded when first accessed +# async def load_prompt_metadata(): +# prompt_metadata = await registry.dotprompt.render_metadata(parsed_prompt) +# +# if variant: +# prompt_metadata['variant'] = variant +# +# if prompt_metadata.get('output', {}).get('schema', {}).get('description') is None: +# prompt_metadata.setdefault('output', {}).setdefault('schema', {}).pop('description', None) +# if prompt_metadata.get('input', {}).get('schema', {}).get('description') is None: +# prompt_metadata.setdefault('input', {}).setdefault('schema', {}).pop('description', None) +# +# metadata = { +# **prompt_metadata.get('metadata', {}), +# 'type': 'prompt', +# 'prompt': { +# **prompt_metadata, +# 'template': parsed_prompt.template, +# }, +# } +# +# if prompt_metadata.get('raw', {}).get('metadata'): +# metadata['metadata'] = {**prompt_metadata['raw']['metadata']} +# +# return { +# 'name': registry_key, +# 'model': prompt_metadata.get('model'), +# 'config': prompt_metadata.get('config'), +# 'tools': prompt_metadata.get('tools'), +# 'description': prompt_metadata.get('description'), +# 'output': { +# 'jsonSchema': prompt_metadata.get('output', {}).get('schema'), +# 'format': prompt_metadata.get('output', {}).get('format'), +# }, +# 'input': { +# 'jsonSchema': prompt_metadata.get('input', {}).get('schema'), +# }, +# 'metadata': metadata, +# 'maxTurns': prompt_metadata.get('raw', {}).get('maxTurns'), +# 'toolChoice': prompt_metadata.get('raw', {}).get('toolChoice'), +# 'returnToolRequests': prompt_metadata.get('raw', {}).get('returnToolRequests'), +# 'messages': parsed_prompt.template, +# } +# +# # Create a factory function that will create the ExecutablePrompt when accessed +# # This is similar to "definePromptAsync" in prompt.ts +# async def create_prompt_from_file(): +# +# metadata = await load_prompt_metadata() +# +# return define_prompt( +# registry=registry, +# variant=metadata.get('variant'), +# model=metadata.get('model'), +# config=metadata.get('config'), +# description=metadata.get('description'), +# input_schema=metadata.get('input', {}).get('jsonSchema'), +# output_schema=metadata.get('output', {}).get('jsonSchema'), +# output_format=metadata.get('output', {}).get('format'), +# messages=metadata.get('messages'), +# max_turns=metadata.get('maxTurns'), +# tool_choice=metadata.get('toolChoice'), +# return_tool_requests=metadata.get('returnToolRequests'), +# metadata=metadata.get('metadata'), +# tools=metadata.get('tools'), +# ) +# +# # Store the async factory in a way that can be accessed later +# # We'll store it in the action metadata +# action_metadata = { +# 'type': 'prompt', +# 'lazy': True, +# 'source': 'file', +# '_async_factory': create_prompt_from_file, +# } +# +# async def prompt_action_fn(Any=None)->GenerateRequest: +# prompt = await create_prompt_from_file() +# options = await prompt.render(input=input) +# +# return await to_generate_request(registry,options) +# +# async def executable_prompt_action_fn(Any=None) -> GenerateActionOptions: +# prompt = await create_prompt_file() +# +# return await prompt.render(input=input) +# +# # register the PROMPT action +# prompt_action = registry.register_action( +# kind=ActionKind.PROMPT, +# name=registry_lookup_key(name, variant, ns), +# fn=prompt_action_fn, +# metadata=action_metadata, +# ) +# # register the EXECUTABLE_PROMPT action +# executable_prompt_action = registry.register_action( +# kind=ActionKind.EXECUTABLE_PROMPT, +# name=registry_lookup_key(name, variant, ns), +# fn=executable_prompt_action_fn, +# metadata=action_metadata, +# ) +# +# prompt_action._async_factory = create_prompt_from_file +# executable_prompt_action._async_factory = create_prompt_from_file +# +# logger.debug(f'Registered prompt "{registry_key}" from "{file_path}"') +# +# def load_prompt_folder_recursively( +# registry: Registry, +# dir_path: str, +# ns: str, +# sub_dir: str = '' +# ) -> None: +# """Recursively load all prompt files from a directory. +# """ +# full_path = dir_path / sub_dir if sub_dir else dir_path +# +# if not full_path.exists() or not full_path.is_dir(): +# return +# +# try: +# for entry in os.scandir(full_path): +# if entry.is_file() and entry.name.endswith('.prompt'): +# if entry.name.startswith('_'): +# # This is a partial +# partial_name = entry.name[1:-7] # Remove "_" prefix and ".prompt" suffix +# with open(entry.path, 'r', encoding='utf-8') as f: +# source = f.read() +# define_partial(registry, partial_name, source) +# logger.debug(f'Registered Dotprompt partial "{partial_name}" from "{entry.path}"') +# else: +# +# load_prompt( +# registry, +# dir_path, +# entry.name, +# sub_dir, +# ns +# ) +# elif entry.is_directory(): +# new_sub_dir = os.path.join(sub_dir, entry.name) if sub_dir else entry.name +# load_prompt_folder_recursively(registry, dir_path, ns, new_sub_dir) +# except PermissionError: +# logger.warning(f'Permission denied accessing directory: {full_path}') +# except Exception as e: +# logger.error(f'Error loading prompts from {full_path}: {e}') +# +# +# def load_prompt_folder( +# registry: Registry, +# dir_path: str | Path = './prompts', +# ns: str = 'dotprompt' +# ) -> None: +# +# path = Path(dir_path).resolve() +# +# if not path.exists(): +# logger.warning(f'Prompt directory does not exist: {path}') +# return +# +# if not path.is_dir(): +# logger.warning(f'Prompt path is not a directory: {path}') +# return +# +# load_prompt_folder_recursively(registry, path, ns, '') +# logger.info(f'Loaded prompts from directory: {path}') +# +# async def lookup_prompt( +# registry: Registry, +# name: str, +# variant: str | None = None, +# # ns: str | None = None +# ) -> ExecutablePrompt: +# """Look up a prompt from the registry. +# """ +# # if ns is None: +# # ns = 'dotprompt' +# +# # lookup_key = registry_lookup_key(name, variant, ns) +# lookup_key = registry_lookup_key(name, variant, None) +# action = registry.lookup_action_by_key(lookup_key) +# +# if action: +# # Get the async factory and create the prompt +# if hasattr(action, '_async_factory'): +# return await action._async_factory() +# elif hasattr(action.fn, '_async_factory'): +# return await action.fn._async_factory() +# elif action.metadata.get('_async_factory'): +# factory = action.metadata['_async_factory'] +# return await factory() +# else: +# # If it's already an ExecutablePrompt, return it +# # Otherwise, try to call it +# import asyncio +# try: +# loop = asyncio.get_event_loop() +# except RuntimeError: +# loop = asyncio.new_event_loop() +# asyncio.set_event_loop(loop) +# result = loop.run_until_complete(action.fn()) +# # If result is an ExecutablePrompt, return it +# if isinstance(result, ExecutablePrompt): +# return result +# # Otherwise, it might be a coroutine +# if asyncio.iscoroutine(result): +# return await result +# return result +# +# variant_str = f" (variant {variant})" if variant else "" +# raise ValueError(f"Prompt {name}{variant_str} not found") +# +# +# async def prompt( +# registry: Registry, +# name: str, +# variant: str | None = None, +# dir: str | Path | None = None +# ) -> ExecutablePrompt: +# """Look up a prompt by name and optional variant. """ +# +# try: +# return await lookup_prompt(registry, name, variant) +# except ValueError: +# pass +# +# # If not found and directory provided, try loading from directory +# if dir is not None: +# load_prompt_folder(registry, dir) +# return await lookup_prompt(registry, name, variant) +# +# # Still not found +# variant_str = f" (variant {variant})" if variant else "" +# raise ValueError(f"Prompt {name}{variant_str} not found") +# +# return await lookup_prompt(registry, name, variant) diff --git a/py/packages/genkit/tests/genkit/blocks/prompt_test.py b/py/packages/genkit/tests/genkit/blocks/prompt_test.py index 1b62b92835..46463beba4 100644 --- a/py/packages/genkit/tests/genkit/blocks/prompt_test.py +++ b/py/packages/genkit/tests/genkit/blocks/prompt_test.py @@ -18,13 +18,18 @@ """Tests for the action module.""" from typing import Any +import tempfile +from pathlib import Path import pytest from pydantic import BaseModel, Field from genkit.ai import Genkit +from genkit.blocks.prompt import load_prompt_folder, lookup_prompt, prompt +from genkit.core.action.types import ActionKind from genkit.core.typing import ( GenerateActionOptions, + GenerateRequest, GenerationCommonConfig, Message, Role, @@ -226,3 +231,108 @@ async def test_prompt_rendering_dotprompt( response = await my_prompt(input, input_option, context=context) assert response.text == want_rendered + + +# Tests for file-based prompt loading and two-action structure +@pytest.mark.asyncio +async def test_file_based_prompt_registers_two_actions() -> None: + """File-based prompts create both PROMPT and EXECUTABLE_PROMPT actions.""" + ai, *_ = setup_test() + + with tempfile.TemporaryDirectory() as tmpdir: + prompt_dir = Path(tmpdir) / 'prompts' + prompt_dir.mkdir() + + # Simple prompt file: name is "filePrompt" + prompt_file = prompt_dir / 'filePrompt.prompt' + prompt_file.write_text('hello {{name}}') + + # Load prompts from directory + load_prompt_folder(ai.registry, prompt_dir) + + # Actions are registered with registry_definition_key (e.g., "dotprompt/filePrompt") + # We need to look them up by kind and name (without the /prompt/ prefix) + action_name = 'dotprompt/filePrompt' # registry_definition_key format + + prompt_action = ai.registry.lookup_action(ActionKind.PROMPT, action_name) + executable_prompt_action = ai.registry.lookup_action( + ActionKind.EXECUTABLE_PROMPT, action_name + ) + + assert prompt_action is not None + assert executable_prompt_action is not None + + +@pytest.mark.asyncio +async def test_prompt_and_executable_prompt_return_types() -> None: + """PROMPT action returns GenerateRequest, EXECUTABLE_PROMPT returns GenerateActionOptions.""" + ai, *_ = setup_test() + + # Test with file-based prompt (which creates both actions) + # Programmatic prompts don't create actions - they're just ExecutablePrompt instances + with tempfile.TemporaryDirectory() as tmpdir: + prompt_dir = Path(tmpdir) / 'prompts' + prompt_dir.mkdir() + + prompt_file = prompt_dir / 'testPrompt.prompt' + prompt_file.write_text('hello {{name}}') + + load_prompt_folder(ai.registry, prompt_dir) + action_name = 'dotprompt/testPrompt' + + prompt_action = ai.registry.lookup_action(ActionKind.PROMPT, action_name) + executable_prompt_action = ai.registry.lookup_action( + ActionKind.EXECUTABLE_PROMPT, action_name + ) + + assert prompt_action is not None + assert executable_prompt_action is not None + + prompt_result = await prompt_action.arun( + input={'name': 'World'} + ) + assert isinstance(prompt_result.response, GenerateRequest) + + exec_result = await executable_prompt_action.arun( + input={'name': 'World'} + ) + assert isinstance(exec_result.response, GenerateActionOptions) + + +@pytest.mark.asyncio +async def test_lookup_prompt_returns_executable_prompt() -> None: + """lookup_prompt should return an ExecutablePrompt that can be called.""" + ai, *_ = setup_test() + + with tempfile.TemporaryDirectory() as tmpdir: + prompt_dir = Path(tmpdir) / 'prompts' + prompt_dir.mkdir() + + prompt_file = prompt_dir / 'lookupTest.prompt' + prompt_file.write_text('hi {{name}}') + + load_prompt_folder(ai.registry, prompt_dir) + + executable = await lookup_prompt(ai.registry, 'lookupTest') + + response = await executable({'name': 'World'}) + assert 'World' in response.text + + +@pytest.mark.asyncio +async def test_prompt_function_uses_lookup_prompt() -> None: + ai, *_ = setup_test() + + with tempfile.TemporaryDirectory() as tmpdir: + prompt_dir = Path(tmpdir) / 'prompts' + prompt_dir.mkdir() + + prompt_file = prompt_dir / 'promptFuncTest.prompt' + prompt_file.write_text('hello {{name}}') + + load_prompt_folder(ai.registry, prompt_dir) + + # Use prompt() function to look up the file-based prompt + executable = await prompt(ai.registry, 'promptFuncTest') + response = await executable({'name': 'World'}) + assert 'World' in response.text diff --git a/py/uv.lock b/py/uv.lock index 2cacda34ef..300a2a19eb 100644 --- a/py/uv.lock +++ b/py/uv.lock @@ -1,8 +1,9 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version < '3.11'", ] @@ -1357,6 +1358,7 @@ version = "0.4.0" source = { editable = "plugins/google-cloud" } dependencies = [ { name = "genkit" }, + { name = "opentelemetry-exporter-gcp-monitoring" }, { name = "opentelemetry-exporter-gcp-trace" }, { name = "strenum", marker = "python_full_version < '3.11'" }, ] @@ -1364,6 +1366,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "genkit", editable = "packages/genkit" }, + { name = "opentelemetry-exporter-gcp-monitoring", specifier = ">=1.9.0" }, { name = "opentelemetry-exporter-gcp-trace", specifier = ">=1.9.0" }, { name = "strenum", marker = "python_full_version < '3.11'", specifier = ">=0.4.15" }, ] @@ -1545,7 +1548,8 @@ wheels = [ [package.optional-dependencies] grpc = [ - { name = "grpcio" }, + { name = "grpcio", version = "1.72.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "grpcio-status" }, ] @@ -1634,6 +1638,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/ce/3bb46615668647e3880292ef2248f39ebcc38b7d083d593d28fa94fe8ca3/google_cloud_firestore-2.20.2-py3-none-any.whl", hash = "sha256:0ff7b4c66e3ad2fe00f7d5d8c15127bf4ff8b316c6e4eb635ac51d9a9bcd828b", size = 358912, upload-time = "2025-04-17T14:30:20.128Z" }, ] +[[package]] +name = "google-cloud-monitoring" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio", version = "1.72.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/b8/7f68a7738cbfef610af532b2fc758e39d852fc93ed3a31bd0e76fd45d2fd/google_cloud_monitoring-2.28.0.tar.gz", hash = "sha256:25175590907e038add644b5b744941d221776342924637095a879973a7c0ac37", size = 393321, upload-time = "2025-10-14T15:42:55.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/d3/02dcf5376cb4b47b9c06eba36d80700d5b0a1510f3fcd47d3abbe4b0f0a3/google_cloud_monitoring-2.28.0-py3-none-any.whl", hash = "sha256:64f4c57cc465dd51cceffe559f0ec6fa9f96aa6d82790cd8d3af6d5cc3795160", size = 384670, upload-time = "2025-10-14T15:42:41.911Z" }, +] + [[package]] name = "google-cloud-resource-manager" version = "1.14.2" @@ -1881,7 +1902,8 @@ wheels = [ [package.optional-dependencies] grpc = [ - { name = "grpcio" }, + { name = "grpcio", version = "1.72.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, ] [[package]] @@ -1942,7 +1964,8 @@ version = "0.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos", extra = ["grpc"] }, - { name = "grpcio" }, + { name = "grpcio", version = "1.72.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/4e/8d0ca3b035e41fe0b3f31ebbb638356af720335e5a11154c330169b40777/grpc_google_iam_v1-0.14.2.tar.gz", hash = "sha256:b3e1fc387a1a329e41672197d0ace9de22c78dd7d215048c4c78712073f7bd20", size = 16259, upload-time = "2025-03-17T11:40:23.586Z" } @@ -1954,6 +1977,11 @@ wheels = [ name = "grpcio" version = "1.72.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version < '3.11'", +] sdist = { url = "https://files.pythonhosted.org/packages/fe/45/ff8c80a5a2e7e520d9c4d3c41484a11d33508253f6f4dd06d2c4b4158999/grpcio-1.72.1.tar.gz", hash = "sha256:87f62c94a40947cec1a0f91f95f5ba0aa8f799f23a1d42ae5be667b6b27b959c", size = 12584286, upload-time = "2025-06-02T10:14:11.595Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5a/a8/a468586ef3db8cd90f507c0e5655c50cdf136e936f674effddacd5e6f83b/grpcio-1.72.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:ce2706ff37be7a6de68fbc4c3f8dde247cab48cc70fee5fedfbc9cd923b4ee5a", size = 5210592, upload-time = "2025-06-02T10:08:34.416Z" }, @@ -1998,13 +2026,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/10/b6186e92eba035315affc30dfeabf65594dd6f778b92627fae5f40e7beec/grpcio-1.72.1-cp313-cp313-win_amd64.whl", hash = "sha256:329cc6ff5b431df9614340d3825b066a1ff0a5809a01ba2e976ef48c65a0490b", size = 4221454, upload-time = "2025-06-02T10:10:16.73Z" }, ] +[[package]] +name = "grpcio" +version = "1.76.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/17/ff4795dc9a34b6aee6ec379f1b66438a3789cd1315aac0cbab60d92f74b3/grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc", size = 5840037, upload-time = "2025-10-21T16:20:25.069Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ff/35f9b96e3fa2f12e1dcd58a4513a2e2294a001d64dec81677361b7040c9a/grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde", size = 11836482, upload-time = "2025-10-21T16:20:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1c/8374990f9545e99462caacea5413ed783014b3b66ace49e35c533f07507b/grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3", size = 6407178, upload-time = "2025-10-21T16:20:32.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/36fd7d7c75a6c12542c90a6d647a27935a1ecaad03e0ffdb7c42db6b04d2/grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990", size = 7075684, upload-time = "2025-10-21T16:20:35.435Z" }, + { url = "https://files.pythonhosted.org/packages/38/f7/e3cdb252492278e004722306c5a8935eae91e64ea11f0af3437a7de2e2b7/grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af", size = 6611133, upload-time = "2025-10-21T16:20:37.541Z" }, + { url = "https://files.pythonhosted.org/packages/7e/20/340db7af162ccd20a0893b5f3c4a5d676af7b71105517e62279b5b61d95a/grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2", size = 7195507, upload-time = "2025-10-21T16:20:39.643Z" }, + { url = "https://files.pythonhosted.org/packages/10/f0/b2160addc1487bd8fa4810857a27132fb4ce35c1b330c2f3ac45d697b106/grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6", size = 8160651, upload-time = "2025-10-21T16:20:42.492Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2c/ac6f98aa113c6ef111b3f347854e99ebb7fb9d8f7bb3af1491d438f62af4/grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3", size = 7620568, upload-time = "2025-10-21T16:20:45.995Z" }, + { url = "https://files.pythonhosted.org/packages/90/84/7852f7e087285e3ac17a2703bc4129fafee52d77c6c82af97d905566857e/grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b", size = 3998879, upload-time = "2025-10-21T16:20:48.592Z" }, + { url = "https://files.pythonhosted.org/packages/10/30/d3d2adcbb6dd3ff59d6ac3df6ef830e02b437fb5c90990429fd180e52f30/grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b", size = 4706892, upload-time = "2025-10-21T16:20:50.697Z" }, + { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, + { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, + { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, + { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, + { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, + { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, + { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, + { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, + { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, + { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, + { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" }, + { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" }, + { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" }, + { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" }, + { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" }, + { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" }, + { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, + { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, + { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, + { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, + { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, + { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, +] + [[package]] name = "grpcio-status" version = "1.72.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, - { name = "grpcio" }, + { name = "grpcio", version = "1.72.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/b8/e563262a30065d3b52b61ca92c427fe2a1b04ba5dfca0415ae0df8ecdac8/grpcio_status-1.72.1.tar.gz", hash = "sha256:627111a87afa920eafb42cc6c50db209d263e07fa51fbb084981ef636566be7b", size = 13646, upload-time = "2025-06-02T10:14:23.666Z" } @@ -2159,7 +2252,8 @@ name = "ipython" version = "9.0.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version >= '3.11' and python_full_version < '3.13'", ] dependencies = [ @@ -3391,6 +3485,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/44/4c45a34def3506122ae61ad684139f0bbc4e00c39555d4f7e20e0e001c8a/opentelemetry_api-1.33.1-py3-none-any.whl", hash = "sha256:4db83ebcf7ea93e64637ec6ee6fabee45c5cbe4abd9cf3da95c43828ddb50b83", size = 65771, upload-time = "2025-05-16T18:52:17.419Z" }, ] +[[package]] +name = "opentelemetry-exporter-gcp-monitoring" +version = "1.11.0a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-cloud-monitoring" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-resourcedetector-gcp" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/48/d1c7d2380bb1754d1eb6a011a2e0de08c6868cb6c0f34bcda0444fa0d614/opentelemetry_exporter_gcp_monitoring-1.11.0a0.tar.gz", hash = "sha256:386276eddbbd978a6f30fafd3397975beeb02a1302bdad554185242a8e2c343c", size = 20828, upload-time = "2025-11-04T19:32:14.522Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/8c/03a6e73e270a9c890dbd6cc1c47c83d86b8a8a974a9168d92e043c6277cc/opentelemetry_exporter_gcp_monitoring-1.11.0a0-py3-none-any.whl", hash = "sha256:b6740cba61b2f9555274829fe87a58447b64d0378f1067a4faebb4f5b364ca22", size = 13611, upload-time = "2025-11-04T19:32:08.212Z" }, +] + [[package]] name = "opentelemetry-exporter-gcp-trace" version = "1.9.0" From 51770e999cd68eb88f28b4e98d0f42684794fbc6 Mon Sep 17 00:00:00 2001 From: Mengqin Shen Date: Tue, 16 Dec 2025 13:22:53 -0800 Subject: [PATCH 2/5] fix: revise formatting in prompt file and add more tests in prompt_test based on code review --- py/packages/genkit/src/genkit/ai/_registry.py | 31 +- .../genkit/src/genkit/blocks/prompt.py | 452 +++--------------- .../genkit/tests/genkit/blocks/prompt_test.py | 205 +++++++- 3 files changed, 280 insertions(+), 408 deletions(-) diff --git a/py/packages/genkit/src/genkit/ai/_registry.py b/py/packages/genkit/src/genkit/ai/_registry.py index 1ef369f71e..c7cb74685a 100644 --- a/py/packages/genkit/src/genkit/ai/_registry.py +++ b/py/packages/genkit/src/genkit/ai/_registry.py @@ -286,8 +286,17 @@ def define_indexer( metadata: dict[str, Any] | None = None, description: str | None = None, ) -> Callable[[Callable], Callable]: + """Define an indexer action. + Args: + name: Name of the indexer. + fn: Function implementing the indexer behavior. + config_schema: Optional schema for indexer configuration. + metadata: Optional metadata for the indexer. + description: Optional description for the indexer. + """ indexer_meta = metadata if metadata else {} + if 'indexer' not in indexer_meta: indexer_meta['indexer'] = {} if 'label' not in indexer_meta['indexer'] or not indexer_meta['indexer']['label']: @@ -315,7 +324,7 @@ def define_evaluator( metadata: dict[str, Any] | None = None, description: str | None = None, ) -> Action: - """Define a evaluator action. + """Define an evaluator action. This action runs the callback function on the every sample of the input dataset. @@ -604,6 +613,26 @@ async def prompt( name: str, variant: str | None = None, ): + """Look up a prompt by name and optional variant. + + This matches the JavaScript prompt() function behavior. + + Can look up prompts that were: + 1. Defined programmatically using define_prompt() + 2. Loaded from .prompt files using load_prompt_folder() + + Args: + registry: The registry to look up the prompt from. + name: The name of the prompt. + variant: Optional variant name. + dir: Optional directory parameter (accepted for compatibility but not used). + + Returns: + An ExecutablePrompt instance. + + Raises: + GenkitError: If the prompt is not found. + """ return await lookup_prompt( registry=self.registry, diff --git a/py/packages/genkit/src/genkit/blocks/prompt.py b/py/packages/genkit/src/genkit/blocks/prompt.py index 1819829f5f..c8278855d1 100644 --- a/py/packages/genkit/src/genkit/blocks/prompt.py +++ b/py/packages/genkit/src/genkit/blocks/prompt.py @@ -22,14 +22,13 @@ generation and management across different parts of the application. """ -import structlog import os -from pathlib import Path - from asyncio import Future from collections.abc import AsyncIterator, Callable +from pathlib import Path from typing import Any +import structlog from dotpromptz.typing import ( DataArgument, PromptFunction, @@ -69,6 +68,7 @@ Tools, ) +logger = structlog.get_logger(__name__) class PromptCache: """Model for a prompt cache.""" @@ -129,9 +129,9 @@ def __init__( tools: list[str] | None = None, tool_choice: ToolChoice | None = None, use: list[ModelMiddleware] | None = None, - # _name: str | None = None, # Internal: prompt name for action lookup - # _ns: str | None = None, # Internal: namespace for action lookup - # _prompt_action: Action | None = None, # Internal: reference to PROMPT action + _name: str | None = None, # prompt name for action lookup + _ns: str | None = None, # namespace for action lookup + _prompt_action: Action | None = None, # reference to PROMPT action # TODO: # docs: list[Document]): ): @@ -180,10 +180,9 @@ def __init__( self._tool_choice = tool_choice self._use = use self._cache_prompt = PromptCache() - # Store name/ns for action lookup (used by as_tool()) - # self._name = _name - # self._ns = _ns - # self._prompt_action = _prompt_action + self._name = _name# Store name/ns for action lookup (used by as_tool()) + self._ns = _ns + self._prompt_action = _prompt_action async def __call__( self, @@ -337,7 +336,6 @@ async def as_tool(self) -> Action: """Expose this prompt as a tool. Returns the PROMPT action, which can be used as a tool. - Similar to JS asTool(). """ # If we have a direct reference to the action, use it if self._prompt_action is not None: @@ -509,14 +507,28 @@ async def to_generate_action_options(registry: Registry, options: PromptConfig) resume=resume, ) -async def to_generate_request( - registry: Registry, - options: GenerateActionOptions -) -> GenerateRequest: - """Convert GenerateActionOptions to GenerateRequest. - Similar to JS toGenerateRequest(). Resolves tools and converts to GenerateRequest format. - """ +async def to_generate_request(registry: Registry, options: GenerateActionOptions) -> GenerateRequest: + """Converts GenerateActionOptions to a GenerateRequest. + + This function resolves tool names into their respective tool definitions + by looking them up in the provided registry. it also validates that the + provided options contain at least one message. + + Args: + registry: The Registry instance used to look up tool actions. + options: The GenerateActionOptions containing the configuration, + messages, and tool references to be converted. + + Returns: + A GenerateRequest object populated with messages, config, resolved + tools, and output configurations. + + Raises: + Exception: If a tool name provided in options cannot be found in + the registry. + GenkitError: If the options do not contain any messages. + """ tools: list[Action] = [] if options.tools: @@ -529,7 +541,7 @@ async def to_generate_request( tool_defs = [to_tool_definition(tool) for tool in tools] if tools else [] if not options.messages: - from genkit.core.error import GenkitError + raise GenkitError( status='INVALID_ARGUMENT', message='at least one message is required in generate request', @@ -549,6 +561,7 @@ async def to_generate_request( ), ) + def _normalize_prompt_arg( prompt: str | Part | list[Part] | None, ) -> list[Part] | None: @@ -763,9 +776,6 @@ async def render_user_prompt( return Message(role=Role.USER, content=_normalize_prompt_arg(options.prompt)) -logger = structlog.get_logger(__name__) - - def registry_definition_key(name: str, variant: str | None = None, ns: str | None = None) -> str: """Generate a registry definition key for a prompt. @@ -784,8 +794,8 @@ def registry_definition_key(name: str, variant: str | None = None, ns: str | Non parts.append(ns) parts.append(name) if variant: - parts[-1] = f"{parts[-1]}.{variant}" - return "/".join(parts) + parts[-1] = f'{parts[-1]}.{variant}' + return '/'.join(parts) def registry_lookup_key(name: str, variant: str | None = None, ns: str | None = None) -> str: @@ -799,14 +809,14 @@ def registry_lookup_key(name: str, variant: str | None = None, ns: str | None = Returns: Registry lookup key string. """ - return f"/prompt/{registry_definition_key(name, variant, ns)}" + return f'/prompt/{registry_definition_key(name, variant, ns)}' def define_partial(registry: Registry, name: str, source: str) -> None: """Define a partial template in the registry. Partials are reusable template fragments that can be included in other prompts. - In JS, files starting with `_` are treated as partials. + Files starting with `_` are treated as partials. Args: registry: The registry to register the partial in. @@ -829,13 +839,7 @@ def define_helper(registry: Registry, name: str, fn: Callable) -> None: logger.debug(f'Registered Dotprompt helper "{name}"') -def load_prompt( - registry: Registry, - path: Path, - filename: str, - prefix: str = '', - ns: str = 'dotprompt' -) -> None: +def load_prompt(registry: Registry, path: Path, filename: str, prefix: str = '', ns: str = 'dotprompt') -> None: """Load a single prompt file and register it in the registry. This function loads a .prompt file, parses it, and registers it as a lazy-loaded @@ -849,26 +853,27 @@ def load_prompt( ns: Namespace for the prompt. """ # Extract name and variant from filename - # Matches JS behavior: prefix is included in name before variant extraction # "myPrompt.prompt" -> name="myPrompt", variant=None # "myPrompt.variant.prompt" -> name="myPrompt", variant="variant" # "subdir/myPrompt.prompt" -> name="subdir/myPrompt", variant=None - base_name = filename[:-7] # Remove ".prompt" extension - # Include prefix in name (matches JS: `${prefix ?? ''}${basename(filename, '.prompt')}`) + if not filename.endswith('.prompt'): + raise ValueError(f"Invalid prompt filename: {filename}. Must end with '.prompt'") + + base_name = filename.removesuffix('.prompt') + if prefix: - name = f"{prefix}{base_name}" + name = f'{prefix}{base_name}' else: name = base_name variant: str | None = None - # Extract variant (matches JS: only takes parts[1], not all remaining parts) + # Extract variant (only takes parts[1], not all remaining parts) if '.' in name: parts = name.split('.') name = parts[0] - variant = parts[1] # Only first part after split (matches JS behavior) + variant = parts[1] # Only first part after split # Build full file path - # Match JS: join(path, prefix ?? '', filename) # prefix may have trailing slash, so we need to handle it if prefix: # Strip trailing slash for path construction (pathlib handles it) @@ -905,7 +910,7 @@ async def load_prompt_metadata(): if variant: prompt_metadata_dict['variant'] = variant - # Clean up null descriptions (matches JS behavior) + # Clean up null descriptions output = prompt_metadata_dict.get('output') if output and isinstance(output, dict): schema = output.get('schema') @@ -989,7 +994,7 @@ async def create_prompt_from_file(): prompt_action = registry.lookup_action_by_key(lookup_key) if prompt_action and prompt_action.kind == ActionKind.PROMPT: executable_prompt._prompt_action = prompt_action - # Also store ExecutablePrompt reference on the action (matches JS __executablePrompt) + # Also store ExecutablePrompt reference on the action prompt_action._executable_prompt = executable_prompt return executable_prompt @@ -1003,15 +1008,12 @@ async def create_prompt_from_file(): '_async_factory': create_prompt_from_file, } - # Create two separate action functions to match JavaScript structure: + # Create two separate action functions : # 1. PROMPT action - returns GenerateRequest (for rendering prompts) # 2. EXECUTABLE_PROMPT action - returns GenerateActionOptions (for executing prompts) async def prompt_action_fn(input: Any = None) -> GenerateRequest: """PROMPT action function - renders prompt and returns GenerateRequest. - - This matches the JavaScript behavior where the PROMPT action's fn - returns GenerateRequest by calling toGenerateRequest(). """ # Load the prompt (lazy loading) prompt = await create_prompt_from_file() @@ -1024,9 +1026,6 @@ async def prompt_action_fn(input: Any = None) -> GenerateRequest: async def executable_prompt_action_fn(input: Any = None) -> GenerateActionOptions: """EXECUTABLE_PROMPT action function - renders prompt and returns GenerateActionOptions. - - This matches the JavaScript behavior where the EXECUTABLE_PROMPT action's fn - returns GenerateActionOptions by calling toGenerateActionOptions(). """ # Load the prompt (lazy loading) prompt = await create_prompt_from_file() @@ -1058,19 +1057,14 @@ async def executable_prompt_action_fn(input: Any = None) -> GenerateActionOption prompt_action._async_factory = create_prompt_from_file executable_prompt_action._async_factory = create_prompt_from_file - # Store ExecutablePrompt reference on actions (matches JS __executablePrompt pattern) + # Store ExecutablePrompt reference on actions # This will be set when the prompt is first accessed (lazy loading) # We'll update it in create_prompt_from_file after the prompt is created logger.debug(f'Registered prompt "{registry_key}" from "{file_path}"') -def load_prompt_folder_recursively( - registry: Registry, - dir_path: Path, - ns: str, - sub_dir: str = '' -) -> None: +def load_prompt_folder_recursively(registry: Registry, dir_path: Path, ns: str, sub_dir: str = '') -> None: """Recursively load all prompt files from a directory. Args: @@ -1097,15 +1091,8 @@ def load_prompt_folder_recursively( logger.debug(f'Registered Dotprompt partial "{partial_name}" from "{entry.path}"') else: # This is a regular prompt - # Match JS: subDir ? `${subDir}/` : '' - prefix_with_slash = f"{sub_dir}/" if sub_dir else '' - load_prompt( - registry, - dir_path, - entry.name, - prefix_with_slash, - ns - ) + prefix_with_slash = f'{sub_dir}/' if sub_dir else '' + load_prompt(registry, dir_path, entry.name, prefix_with_slash, ns) elif entry.is_directory(): # Recursively process subdirectories new_sub_dir = os.path.join(sub_dir, entry.name) if sub_dir else entry.name @@ -1116,11 +1103,7 @@ def load_prompt_folder_recursively( logger.error(f'Error loading prompts from {full_path}: {e}') -def load_prompt_folder( - registry: Registry, - dir_path: str | Path = './prompts', - ns: str = 'dotprompt' -) -> None: +def load_prompt_folder(registry: Registry, dir_path: str | Path = './prompts', ns: str = 'dotprompt') -> None: """Load all prompt files from a directory. This is the main entry point for loading prompts from a directory. @@ -1145,15 +1128,9 @@ def load_prompt_folder( logger.info(f'Loaded prompts from directory: {path}') -async def lookup_prompt( - registry: Registry, - name: str, - variant: str | None = None -) -> ExecutablePrompt: +async def lookup_prompt(registry: Registry, name: str, variant: str | None = None) -> ExecutablePrompt: """Look up a prompt from the registry. - This matches the JavaScript lookupPrompt function behavior. - Args: registry: The registry to look up the prompt from. name: The name of the prompt. @@ -1165,9 +1142,6 @@ async def lookup_prompt( Raises: GenkitError: If the prompt is not found. """ - from genkit.core.error import GenkitError - - # Match JS: registryLookupKey(name, variant) - no ns parameter # Try without namespace first (for programmatic prompts) # Use create_action_key to build the full key: "/prompt/" definition_key = registry_definition_key(name, variant, None) @@ -1182,8 +1156,7 @@ async def lookup_prompt( action = registry.lookup_action_by_key(lookup_key) if action: - # Match JS: (registryPrompt as PromptAction).__executablePrompt - # First check if we've stored the ExecutablePrompt directly (matches JS pattern) + # First check if we've stored the ExecutablePrompt directly if hasattr(action, '_executable_prompt') and action._executable_prompt is not None: return action._executable_prompt elif hasattr(action, '_async_factory'): @@ -1209,8 +1182,7 @@ async def lookup_prompt( message=f'Prompt action found but no ExecutablePrompt available for {name}', ) - # Match JS: throw GenkitError with status 'NOT_FOUND' - variant_str = f" (variant {variant})" if variant else "" + variant_str = f' (variant {variant})' if variant else '' raise GenkitError( status='NOT_FOUND', message=f'Prompt {name}{variant_str} not found', @@ -1221,12 +1193,10 @@ async def prompt( registry: Registry, name: str, variant: str | None = None, - dir: str | Path | None = None # Accepted but not used, matching JS behavior + dir: str | Path | None = None, # Accepted but not used ) -> ExecutablePrompt: """Look up a prompt by name and optional variant. - This matches the JavaScript prompt() function behavior. - Can look up prompts that were: 1. Defined programmatically using define_prompt() 2. Loaded from .prompt files using load_prompt_folder() @@ -1243,311 +1213,5 @@ async def prompt( Raises: GenkitError: If the prompt is not found. """ - # Match JS: return await lookupPrompt(registry, name, options?.variant) - # The dir parameter is accepted in JS but not used in the prompt function + return await lookup_prompt(registry, name, variant) -# logger = structlog.get_logger(__name__) -# -# def registry_definition_key(name: str, variant: str | None = None, ns: str | None = None) -> str: -# """ -# Format: "ns/name.variant" where ns and variant are optional. -# """ -# parts = [] -# if ns: -# parts.append(ns) -# parts.append(name) -# if variant: -# parts[-1] = f"{parts[-1]}.{variant}" -# return "/".join(parts) -# -# -# def registry_lookup_key(name: str, variant: str | None = None, ns: str | None = None) -> str: -# -# return f"/prompt/{registry_definition_key(name, variant, ns)}" -# -# -# def define_partial(registry: Registry, name: str, source: str) -> None: -# -# registry.dotprompt.define_partial(name, source) -# logger.debug(f'Registered Dotprompt partial "{name}"') -# -# -# def define_helper(registry: Registry, name: str, fn: Callable) -> None: -# -# registry.dotprompt.define_helper(name, fn) -# logger.debug(f'Registered Dotprompt helper "{name}"') -# -# -# def load_prompt( -# registry: Registry, -# path: str, -# filename: str, -# prefix: str = '', -# ns: str = 'dotprompt' -# ) -> None: -# -# # Extract name and variant from filename -# # "myPrompt.prompt" -> name="myPrompt", variant=None -# # "myPrompt.variant.prompt" -> name="myPrompt", variant="variant" -# # "subdir/myPrompt.prompt" -> name="subdir/myPrompt", variant=None -# base_name = filename[:-7] -# if prefix: -# name = f"{prefix}{base_name}" -# else: -# name = base_name -# -# variant: str | None = None -# -# if '.' in name: -# parts = base_name.split('.') -# name = parts[0] -# variant = parts[1] -# -# if prefix: -# prefix_clean = prefix.rstrip('/') -# file_path = path/prefix_clean/filename -# else: -# file_path = path/filename -# -# with open(file_path, 'r', encoding='utf-8') as f: -# source = f.read() -# -# parsed_prompt = registry.dotprompt.parse(source) -# registry_key = registry_definition_key(name, variant, ns) -# -# # Create a lazy-loaded prompt definition,the prompt will only be fully loaded when first accessed -# async def load_prompt_metadata(): -# prompt_metadata = await registry.dotprompt.render_metadata(parsed_prompt) -# -# if variant: -# prompt_metadata['variant'] = variant -# -# if prompt_metadata.get('output', {}).get('schema', {}).get('description') is None: -# prompt_metadata.setdefault('output', {}).setdefault('schema', {}).pop('description', None) -# if prompt_metadata.get('input', {}).get('schema', {}).get('description') is None: -# prompt_metadata.setdefault('input', {}).setdefault('schema', {}).pop('description', None) -# -# metadata = { -# **prompt_metadata.get('metadata', {}), -# 'type': 'prompt', -# 'prompt': { -# **prompt_metadata, -# 'template': parsed_prompt.template, -# }, -# } -# -# if prompt_metadata.get('raw', {}).get('metadata'): -# metadata['metadata'] = {**prompt_metadata['raw']['metadata']} -# -# return { -# 'name': registry_key, -# 'model': prompt_metadata.get('model'), -# 'config': prompt_metadata.get('config'), -# 'tools': prompt_metadata.get('tools'), -# 'description': prompt_metadata.get('description'), -# 'output': { -# 'jsonSchema': prompt_metadata.get('output', {}).get('schema'), -# 'format': prompt_metadata.get('output', {}).get('format'), -# }, -# 'input': { -# 'jsonSchema': prompt_metadata.get('input', {}).get('schema'), -# }, -# 'metadata': metadata, -# 'maxTurns': prompt_metadata.get('raw', {}).get('maxTurns'), -# 'toolChoice': prompt_metadata.get('raw', {}).get('toolChoice'), -# 'returnToolRequests': prompt_metadata.get('raw', {}).get('returnToolRequests'), -# 'messages': parsed_prompt.template, -# } -# -# # Create a factory function that will create the ExecutablePrompt when accessed -# # This is similar to "definePromptAsync" in prompt.ts -# async def create_prompt_from_file(): -# -# metadata = await load_prompt_metadata() -# -# return define_prompt( -# registry=registry, -# variant=metadata.get('variant'), -# model=metadata.get('model'), -# config=metadata.get('config'), -# description=metadata.get('description'), -# input_schema=metadata.get('input', {}).get('jsonSchema'), -# output_schema=metadata.get('output', {}).get('jsonSchema'), -# output_format=metadata.get('output', {}).get('format'), -# messages=metadata.get('messages'), -# max_turns=metadata.get('maxTurns'), -# tool_choice=metadata.get('toolChoice'), -# return_tool_requests=metadata.get('returnToolRequests'), -# metadata=metadata.get('metadata'), -# tools=metadata.get('tools'), -# ) -# -# # Store the async factory in a way that can be accessed later -# # We'll store it in the action metadata -# action_metadata = { -# 'type': 'prompt', -# 'lazy': True, -# 'source': 'file', -# '_async_factory': create_prompt_from_file, -# } -# -# async def prompt_action_fn(Any=None)->GenerateRequest: -# prompt = await create_prompt_from_file() -# options = await prompt.render(input=input) -# -# return await to_generate_request(registry,options) -# -# async def executable_prompt_action_fn(Any=None) -> GenerateActionOptions: -# prompt = await create_prompt_file() -# -# return await prompt.render(input=input) -# -# # register the PROMPT action -# prompt_action = registry.register_action( -# kind=ActionKind.PROMPT, -# name=registry_lookup_key(name, variant, ns), -# fn=prompt_action_fn, -# metadata=action_metadata, -# ) -# # register the EXECUTABLE_PROMPT action -# executable_prompt_action = registry.register_action( -# kind=ActionKind.EXECUTABLE_PROMPT, -# name=registry_lookup_key(name, variant, ns), -# fn=executable_prompt_action_fn, -# metadata=action_metadata, -# ) -# -# prompt_action._async_factory = create_prompt_from_file -# executable_prompt_action._async_factory = create_prompt_from_file -# -# logger.debug(f'Registered prompt "{registry_key}" from "{file_path}"') -# -# def load_prompt_folder_recursively( -# registry: Registry, -# dir_path: str, -# ns: str, -# sub_dir: str = '' -# ) -> None: -# """Recursively load all prompt files from a directory. -# """ -# full_path = dir_path / sub_dir if sub_dir else dir_path -# -# if not full_path.exists() or not full_path.is_dir(): -# return -# -# try: -# for entry in os.scandir(full_path): -# if entry.is_file() and entry.name.endswith('.prompt'): -# if entry.name.startswith('_'): -# # This is a partial -# partial_name = entry.name[1:-7] # Remove "_" prefix and ".prompt" suffix -# with open(entry.path, 'r', encoding='utf-8') as f: -# source = f.read() -# define_partial(registry, partial_name, source) -# logger.debug(f'Registered Dotprompt partial "{partial_name}" from "{entry.path}"') -# else: -# -# load_prompt( -# registry, -# dir_path, -# entry.name, -# sub_dir, -# ns -# ) -# elif entry.is_directory(): -# new_sub_dir = os.path.join(sub_dir, entry.name) if sub_dir else entry.name -# load_prompt_folder_recursively(registry, dir_path, ns, new_sub_dir) -# except PermissionError: -# logger.warning(f'Permission denied accessing directory: {full_path}') -# except Exception as e: -# logger.error(f'Error loading prompts from {full_path}: {e}') -# -# -# def load_prompt_folder( -# registry: Registry, -# dir_path: str | Path = './prompts', -# ns: str = 'dotprompt' -# ) -> None: -# -# path = Path(dir_path).resolve() -# -# if not path.exists(): -# logger.warning(f'Prompt directory does not exist: {path}') -# return -# -# if not path.is_dir(): -# logger.warning(f'Prompt path is not a directory: {path}') -# return -# -# load_prompt_folder_recursively(registry, path, ns, '') -# logger.info(f'Loaded prompts from directory: {path}') -# -# async def lookup_prompt( -# registry: Registry, -# name: str, -# variant: str | None = None, -# # ns: str | None = None -# ) -> ExecutablePrompt: -# """Look up a prompt from the registry. -# """ -# # if ns is None: -# # ns = 'dotprompt' -# -# # lookup_key = registry_lookup_key(name, variant, ns) -# lookup_key = registry_lookup_key(name, variant, None) -# action = registry.lookup_action_by_key(lookup_key) -# -# if action: -# # Get the async factory and create the prompt -# if hasattr(action, '_async_factory'): -# return await action._async_factory() -# elif hasattr(action.fn, '_async_factory'): -# return await action.fn._async_factory() -# elif action.metadata.get('_async_factory'): -# factory = action.metadata['_async_factory'] -# return await factory() -# else: -# # If it's already an ExecutablePrompt, return it -# # Otherwise, try to call it -# import asyncio -# try: -# loop = asyncio.get_event_loop() -# except RuntimeError: -# loop = asyncio.new_event_loop() -# asyncio.set_event_loop(loop) -# result = loop.run_until_complete(action.fn()) -# # If result is an ExecutablePrompt, return it -# if isinstance(result, ExecutablePrompt): -# return result -# # Otherwise, it might be a coroutine -# if asyncio.iscoroutine(result): -# return await result -# return result -# -# variant_str = f" (variant {variant})" if variant else "" -# raise ValueError(f"Prompt {name}{variant_str} not found") -# -# -# async def prompt( -# registry: Registry, -# name: str, -# variant: str | None = None, -# dir: str | Path | None = None -# ) -> ExecutablePrompt: -# """Look up a prompt by name and optional variant. """ -# -# try: -# return await lookup_prompt(registry, name, variant) -# except ValueError: -# pass -# -# # If not found and directory provided, try loading from directory -# if dir is not None: -# load_prompt_folder(registry, dir) -# return await lookup_prompt(registry, name, variant) -# -# # Still not found -# variant_str = f" (variant {variant})" if variant else "" -# raise ValueError(f"Prompt {name}{variant_str} not found") -# -# return await lookup_prompt(registry, name, variant) diff --git a/py/packages/genkit/tests/genkit/blocks/prompt_test.py b/py/packages/genkit/tests/genkit/blocks/prompt_test.py index 46463beba4..4789733c4e 100644 --- a/py/packages/genkit/tests/genkit/blocks/prompt_test.py +++ b/py/packages/genkit/tests/genkit/blocks/prompt_test.py @@ -17,9 +17,9 @@ """Tests for the action module.""" -from typing import Any import tempfile from pathlib import Path +from typing import Any import pytest from pydantic import BaseModel, Field @@ -233,6 +233,193 @@ async def test_prompt_rendering_dotprompt( assert response.text == want_rendered +# Tests for prompt variants and partials +@pytest.mark.asyncio +async def test_load_prompt_variant() -> None: + """Test loading and using a prompt variant.""" + ai, *_ = setup_test() + + with tempfile.TemporaryDirectory() as tmpdir: + prompt_dir = Path(tmpdir) / 'prompts' + prompt_dir.mkdir() + + # Create base prompt + base_prompt = prompt_dir / 'greeting.prompt' + base_prompt.write_text('---\nmodel: echoModel\n---\nHello {{name}}!') + + # Create variant prompt + variant_prompt = prompt_dir / 'greeting.casual.prompt' + variant_prompt.write_text('---\nmodel: echoModel\n---\nHey {{name}}, what\'s up?') + + load_prompt_folder(ai.registry, prompt_dir) + + # Test base prompt + base_exec = await prompt(ai.registry, 'greeting') + base_response = await base_exec({'name': 'Alice'}) + assert 'Hello' in base_response.text + assert 'Alice' in base_response.text + + # Test variant prompt + casual_exec = await prompt(ai.registry, 'greeting', variant='casual') + casual_response = await casual_exec({'name': 'Bob'}) + assert 'Hey' in casual_response.text or 'what\'s up' in casual_response.text.lower() + assert 'Bob' in casual_response.text + + +@pytest.mark.asyncio +async def test_load_and_use_partial() -> None: + """Test loading and using partials in prompts.""" + ai, *_ = setup_test() + + with tempfile.TemporaryDirectory() as tmpdir: + prompt_dir = Path(tmpdir) / 'prompts' + prompt_dir.mkdir() + + # Create partial + partial_file = prompt_dir / '_greeting.prompt' + partial_file.write_text('Hello from partial!') + + # Create prompt that uses the partial + prompt_file = prompt_dir / 'story.prompt' + prompt_file.write_text('---\nmodel: echoModel\n---\n{{>greeting}} Tell me about {{topic}}.') + + load_prompt_folder(ai.registry, prompt_dir) + + story_exec = await prompt(ai.registry, 'story') + response = await story_exec({'topic': 'space'}) + + # The partial should be included in the output + assert 'Hello from partial' in response.text or 'space' in response.text + + +@pytest.mark.asyncio +async def test_prompt_with_messages_list() -> None: + """Test prompt with explicit messages list.""" + ai, *_ = setup_test() + + messages = [ + Message(role=Role.SYSTEM, content=[TextPart(text='You are helpful')]), + Message(role=Role.USER, content=[TextPart(text='Hi there')]), + ] + + my_prompt = ai.define_prompt( + messages=messages, + prompt='How can I help?', + ) + + response = await my_prompt() + + # Should include system, user history, and final prompt + assert 'helpful' in response.text.lower() or 'Hi there' in response.text + + +@pytest.mark.asyncio +async def test_messages_with_explicit_override() -> None: + """Test that explicit messages in render options are included.""" + ai, *_ = setup_test() + + my_prompt = ai.define_prompt( + prompt='Final question', + ) + + override_messages = [ + Message(role=Role.USER, content=[TextPart(text='First message')]), + Message(role=Role.MODEL, content=[TextPart(text='First response')]), + ] + + # The override messages should be prepended to the prompt + rendered = await my_prompt.render(input=None, config=None) + + # Check that we have the final prompt message + assert any('Final question' in str(msg) for msg in rendered.messages) + + +@pytest.mark.asyncio +async def test_prompt_with_tools_list() -> None: + """Test prompt with tools parameter.""" + ai, *_ = setup_test() + + class ToolInput(BaseModel): + value: int = Field(description='A value') + + @ai.tool(name='myTool') + def my_tool(input: ToolInput): + return input.value * 2 + + my_prompt = ai.define_prompt( + prompt='Use the tool', + tools=['myTool'], + ) + + rendered = await my_prompt.render() + + # Verify tools are in the rendered options + assert rendered.tools is not None + assert 'myTool' in rendered.tools + + +@pytest.mark.asyncio +async def test_system_and_prompt_together() -> None: + """Test rendering system, messages, and prompt in correct order.""" + ai, *_ = setup_test() + + my_prompt = ai.define_prompt( + system='System instruction', + messages=[ + Message(role=Role.USER, content=[TextPart(text='History user')]), + Message(role=Role.MODEL, content=[TextPart(text='History model')]), + ], + prompt='Final prompt', + ) + + response = await my_prompt() + + # All parts should be in the response + text = response.text.lower() + assert 'system' in text or 'instruction' in text + assert 'history' in text or 'final' in text or 'prompt' in text + + +@pytest.mark.asyncio +async def test_prompt_with_output_schema() -> None: + """Test that output schema is preserved in rendering.""" + ai, *_ = setup_test() + + class OutputSchema(BaseModel): + name: str = Field(description='A name') + age: int = Field(description='An age') + + my_prompt = ai.define_prompt( + prompt='Generate a person', + output_schema=OutputSchema, + output_format='json', + ) + + rendered = await my_prompt.render() + + # Verify output configuration + assert rendered.output is not None + assert rendered.output.format == 'json' + assert rendered.output.json_schema is not None + + +@pytest.mark.asyncio +async def test_config_merge_priority() -> None: + """Test that runtime config overrides definition config.""" + ai, *_ = setup_test() + + my_prompt = ai.define_prompt( + prompt='test', + config={'temperature': 0.5, 'banana': 'yellow'}, + ) + + # Runtime config should override temperature but keep banana + rendered = await my_prompt.render(config={'temperature': 0.9}) + + assert rendered.config is not None + assert rendered.config.temperature == 0.9 + + # Tests for file-based prompt loading and two-action structure @pytest.mark.asyncio async def test_file_based_prompt_registers_two_actions() -> None: @@ -255,9 +442,7 @@ async def test_file_based_prompt_registers_two_actions() -> None: action_name = 'dotprompt/filePrompt' # registry_definition_key format prompt_action = ai.registry.lookup_action(ActionKind.PROMPT, action_name) - executable_prompt_action = ai.registry.lookup_action( - ActionKind.EXECUTABLE_PROMPT, action_name - ) + executable_prompt_action = ai.registry.lookup_action(ActionKind.EXECUTABLE_PROMPT, action_name) assert prompt_action is not None assert executable_prompt_action is not None @@ -281,21 +466,15 @@ async def test_prompt_and_executable_prompt_return_types() -> None: action_name = 'dotprompt/testPrompt' prompt_action = ai.registry.lookup_action(ActionKind.PROMPT, action_name) - executable_prompt_action = ai.registry.lookup_action( - ActionKind.EXECUTABLE_PROMPT, action_name - ) + executable_prompt_action = ai.registry.lookup_action(ActionKind.EXECUTABLE_PROMPT, action_name) assert prompt_action is not None assert executable_prompt_action is not None - prompt_result = await prompt_action.arun( - input={'name': 'World'} - ) + prompt_result = await prompt_action.arun(input={'name': 'World'}) assert isinstance(prompt_result.response, GenerateRequest) - exec_result = await executable_prompt_action.arun( - input={'name': 'World'} - ) + exec_result = await executable_prompt_action.arun(input={'name': 'World'}) assert isinstance(exec_result.response, GenerateActionOptions) From 94da1cc0290ee75ceb3920a9f3cd13ce0b065a9f Mon Sep 17 00:00:00 2001 From: Mengqin Shen Date: Tue, 16 Dec 2025 13:31:14 -0800 Subject: [PATCH 3/5] fix: raise a GenkitError for consistency instead of a generic Exception --- py/packages/genkit/src/genkit/ai/_registry.py | 29 +++++++------ .../genkit/src/genkit/blocks/prompt.py | 43 +++++++++---------- .../genkit/tests/genkit/blocks/prompt_test.py | 4 +- py/plugins/anthropic/pyproject.toml | 16 +++++++ .../plugins/firebase/tests/test_telemetry.py | 32 +++++++------- py/samples/anthropic-hello/pyproject.toml | 34 +++++++++++---- 6 files changed, 95 insertions(+), 63 deletions(-) diff --git a/py/packages/genkit/src/genkit/ai/_registry.py b/py/packages/genkit/src/genkit/ai/_registry.py index c7cb74685a..e186bdaf82 100644 --- a/py/packages/genkit/src/genkit/ai/_registry.py +++ b/py/packages/genkit/src/genkit/ai/_registry.py @@ -615,24 +615,24 @@ async def prompt( ): """Look up a prompt by name and optional variant. - This matches the JavaScript prompt() function behavior. + This matches the JavaScript prompt() function behavior. - Can look up prompts that were: - 1. Defined programmatically using define_prompt() - 2. Loaded from .prompt files using load_prompt_folder() + Can look up prompts that were: + 1. Defined programmatically using define_prompt() + 2. Loaded from .prompt files using load_prompt_folder() - Args: - registry: The registry to look up the prompt from. - name: The name of the prompt. - variant: Optional variant name. - dir: Optional directory parameter (accepted for compatibility but not used). + Args: + registry: The registry to look up the prompt from. + name: The name of the prompt. + variant: Optional variant name. + dir: Optional directory parameter (accepted for compatibility but not used). - Returns: - An ExecutablePrompt instance. + Returns: + An ExecutablePrompt instance. - Raises: - GenkitError: If the prompt is not found. - """ + Raises: + GenkitError: If the prompt is not found. + """ return await lookup_prompt( registry=self.registry, @@ -640,6 +640,7 @@ async def prompt( variant=variant, ) + class FlowWrapper: """A wapper for flow functions to add `stream` method.""" diff --git a/py/packages/genkit/src/genkit/blocks/prompt.py b/py/packages/genkit/src/genkit/blocks/prompt.py index c8278855d1..1f2f313034 100644 --- a/py/packages/genkit/src/genkit/blocks/prompt.py +++ b/py/packages/genkit/src/genkit/blocks/prompt.py @@ -70,6 +70,7 @@ logger = structlog.get_logger(__name__) + class PromptCache: """Model for a prompt cache.""" @@ -180,7 +181,7 @@ def __init__( self._tool_choice = tool_choice self._use = use self._cache_prompt = PromptCache() - self._name = _name# Store name/ns for action lookup (used by as_tool()) + self._name = _name # Store name/ns for action lookup (used by as_tool()) self._ns = _ns self._prompt_action = _prompt_action @@ -360,6 +361,7 @@ async def as_tool(self) -> Action: return action + def define_prompt( registry: Registry, variant: str | None = None, @@ -511,37 +513,36 @@ async def to_generate_action_options(registry: Registry, options: PromptConfig) async def to_generate_request(registry: Registry, options: GenerateActionOptions) -> GenerateRequest: """Converts GenerateActionOptions to a GenerateRequest. - This function resolves tool names into their respective tool definitions - by looking them up in the provided registry. it also validates that the - provided options contain at least one message. + This function resolves tool names into their respective tool definitions + by looking them up in the provided registry. it also validates that the + provided options contain at least one message. - Args: - registry: The Registry instance used to look up tool actions. - options: The GenerateActionOptions containing the configuration, - messages, and tool references to be converted. + Args: + registry: The Registry instance used to look up tool actions. + options: The GenerateActionOptions containing the configuration, + messages, and tool references to be converted. - Returns: - A GenerateRequest object populated with messages, config, resolved - tools, and output configurations. + Returns: + A GenerateRequest object populated with messages, config, resolved + tools, and output configurations. - Raises: - Exception: If a tool name provided in options cannot be found in - the registry. - GenkitError: If the options do not contain any messages. - """ + Raises: + Exception: If a tool name provided in options cannot be found in + the registry. + GenkitError: If the options do not contain any messages. + """ tools: list[Action] = [] if options.tools: for tool_name in options.tools: tool_action = registry.lookup_action(ActionKind.TOOL, tool_name) if tool_action is None: - raise Exception(f'Unable to resolve tool {tool_name}') + raise GenkitError(status='NOT_FOUND', message=f'Unable to resolve tool {tool_name}') tools.append(tool_action) tool_defs = [to_tool_definition(tool) for tool in tools] if tools else [] if not options.messages: - raise GenkitError( status='INVALID_ARGUMENT', message='at least one message is required in generate request', @@ -1013,8 +1014,7 @@ async def create_prompt_from_file(): # 2. EXECUTABLE_PROMPT action - returns GenerateActionOptions (for executing prompts) async def prompt_action_fn(input: Any = None) -> GenerateRequest: - """PROMPT action function - renders prompt and returns GenerateRequest. - """ + """PROMPT action function - renders prompt and returns GenerateRequest.""" # Load the prompt (lazy loading) prompt = await create_prompt_from_file() @@ -1025,8 +1025,7 @@ async def prompt_action_fn(input: Any = None) -> GenerateRequest: return await to_generate_request(registry, options) async def executable_prompt_action_fn(input: Any = None) -> GenerateActionOptions: - """EXECUTABLE_PROMPT action function - renders prompt and returns GenerateActionOptions. - """ + """EXECUTABLE_PROMPT action function - renders prompt and returns GenerateActionOptions.""" # Load the prompt (lazy loading) prompt = await create_prompt_from_file() diff --git a/py/packages/genkit/tests/genkit/blocks/prompt_test.py b/py/packages/genkit/tests/genkit/blocks/prompt_test.py index 4789733c4e..d41e570a98 100644 --- a/py/packages/genkit/tests/genkit/blocks/prompt_test.py +++ b/py/packages/genkit/tests/genkit/blocks/prompt_test.py @@ -249,7 +249,7 @@ async def test_load_prompt_variant() -> None: # Create variant prompt variant_prompt = prompt_dir / 'greeting.casual.prompt' - variant_prompt.write_text('---\nmodel: echoModel\n---\nHey {{name}}, what\'s up?') + variant_prompt.write_text("---\nmodel: echoModel\n---\nHey {{name}}, what's up?") load_prompt_folder(ai.registry, prompt_dir) @@ -262,7 +262,7 @@ async def test_load_prompt_variant() -> None: # Test variant prompt casual_exec = await prompt(ai.registry, 'greeting', variant='casual') casual_response = await casual_exec({'name': 'Bob'}) - assert 'Hey' in casual_response.text or 'what\'s up' in casual_response.text.lower() + assert 'Hey' in casual_response.text or "what's up" in casual_response.text.lower() assert 'Bob' in casual_response.text diff --git a/py/plugins/anthropic/pyproject.toml b/py/plugins/anthropic/pyproject.toml index e21fada3c8..9fbf89959c 100644 --- a/py/plugins/anthropic/pyproject.toml +++ b/py/plugins/anthropic/pyproject.toml @@ -1,3 +1,19 @@ +# 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" }] classifiers = [ diff --git a/py/plugins/firebase/src/genkit/plugins/firebase/tests/test_telemetry.py b/py/plugins/firebase/src/genkit/plugins/firebase/tests/test_telemetry.py index d5e9955ca7..c420859ce3 100644 --- a/py/plugins/firebase/src/genkit/plugins/firebase/tests/test_telemetry.py +++ b/py/plugins/firebase/src/genkit/plugins/firebase/tests/test_telemetry.py @@ -23,8 +23,8 @@ def _create_model_span( - model_name: str = "gemini-pro", - path: str = "/{myflow,t:flow}", + model_name: str = 'gemini-pro', + path: str = '/{myflow,t:flow}', output: str = '{"usage": {"inputTokens": 100, "outputTokens": 50}}', is_ok: bool = True, start_time: int = 1000000000, @@ -45,11 +45,11 @@ def _create_model_span( """ mock_span = MagicMock(spec=ReadableSpan) mock_span.attributes = { - "genkit:type": "action", - "genkit:metadata:subtype": "model", - "genkit:name": model_name, - "genkit:path": path, - "genkit:output": output, + 'genkit:type': 'action', + 'genkit:metadata:subtype': 'model', + 'genkit:name': model_name, + 'genkit:path': path, + 'genkit:output': output, } mock_span.status.is_ok = is_ok mock_span.start_time = start_time @@ -57,18 +57,18 @@ def _create_model_span( return mock_span -@patch("genkit.plugins.firebase.add_gcp_telemetry") +@patch('genkit.plugins.firebase.add_gcp_telemetry') def test_firebase_telemetry_delegates_to_gcp(mock_add_gcp_telemetry): """Test that Firebase telemetry delegates to GCP telemetry.""" add_firebase_telemetry() mock_add_gcp_telemetry.assert_called_once_with(force_export=False) -@patch("genkit.plugins.google_cloud.telemetry.metrics._output_tokens") -@patch("genkit.plugins.google_cloud.telemetry.metrics._input_tokens") -@patch("genkit.plugins.google_cloud.telemetry.metrics._latency") -@patch("genkit.plugins.google_cloud.telemetry.metrics._failures") -@patch("genkit.plugins.google_cloud.telemetry.metrics._requests") +@patch('genkit.plugins.google_cloud.telemetry.metrics._output_tokens') +@patch('genkit.plugins.google_cloud.telemetry.metrics._input_tokens') +@patch('genkit.plugins.google_cloud.telemetry.metrics._latency') +@patch('genkit.plugins.google_cloud.telemetry.metrics._failures') +@patch('genkit.plugins.google_cloud.telemetry.metrics._requests') def test_record_generate_metrics_with_model_action( mock_requests, mock_failures, @@ -91,8 +91,8 @@ def test_record_generate_metrics_with_model_action( # Create test span using helper mock_span = _create_model_span( - model_name="gemini-pro", - path="/{myflow,t:flow}", + model_name='gemini-pro', + path='/{myflow,t:flow}', output='{"usage": {"inputTokens": 100, "outputTokens": 50}}', ) @@ -100,7 +100,7 @@ def test_record_generate_metrics_with_model_action( record_generate_metrics(mock_span) # Verify dimensions - expected_dimensions = {"model": "gemini-pro", "source": "myflow", "error": "none"} + expected_dimensions = {'model': 'gemini-pro', 'source': 'myflow', 'error': 'none'} # Verify requests counter mock_request_counter.add.assert_called_once_with(1, expected_dimensions) diff --git a/py/samples/anthropic-hello/pyproject.toml b/py/samples/anthropic-hello/pyproject.toml index 0a923fe7e5..17ec62d435 100644 --- a/py/samples/anthropic-hello/pyproject.toml +++ b/py/samples/anthropic-hello/pyproject.toml @@ -1,21 +1,37 @@ +# 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] -name = "anthropic-hello" -version = "0.1.0" -description = "Anthropic Hello Sample" -requires-python = ">=3.10" dependencies = [ - "genkit", - "genkit-plugin-anthropic", - "pydantic>=2.0.0", - "structlog>=24.0.0", + "genkit", + "genkit-plugin-anthropic", + "pydantic>=2.0.0", + "structlog>=24.0.0", ] +description = "Anthropic Hello Sample" +name = "anthropic-hello" +requires-python = ">=3.10" +version = "0.1.0" [tool.uv.sources] genkit-plugin-anthropic = { workspace = true } [build-system] -requires = ["hatchling"] build-backend = "hatchling.build" +requires = ["hatchling"] [tool.hatch.build.targets.wheel] packages = ["src"] From 910b30711b2f773dad40aefd8bbbe4d9a8f6d0e5 Mon Sep 17 00:00:00 2001 From: Mengqin Shen Date: Tue, 16 Dec 2025 13:38:55 -0800 Subject: [PATCH 4/5] fix: raise based on gemini-code-assist comments --- py/packages/genkit/src/genkit/blocks/prompt.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/py/packages/genkit/src/genkit/blocks/prompt.py b/py/packages/genkit/src/genkit/blocks/prompt.py index 1f2f313034..adadc62fb1 100644 --- a/py/packages/genkit/src/genkit/blocks/prompt.py +++ b/py/packages/genkit/src/genkit/blocks/prompt.py @@ -27,6 +27,7 @@ from collections.abc import AsyncIterator, Callable from pathlib import Path from typing import Any +import weakref import structlog from dotpromptz.typing import ( @@ -985,8 +986,8 @@ async def create_prompt_from_file(): return_tool_requests=metadata.get('returnToolRequests'), metadata=metadata.get('metadata'), tools=metadata.get('tools'), - # _name=name, # Store name for action lookup - # _ns=ns, # Store namespace for action lookup + _name=name, # Store name for action lookup + _ns=ns, # Store namespace for action lookup ) # Store reference to PROMPT action on the ExecutablePrompt @@ -996,7 +997,8 @@ async def create_prompt_from_file(): if prompt_action and prompt_action.kind == ActionKind.PROMPT: executable_prompt._prompt_action = prompt_action # Also store ExecutablePrompt reference on the action - prompt_action._executable_prompt = executable_prompt + # prompt_action._executable_prompt = executable_prompt + prompt_action._executable_prompt = weakref.ref(executable_prompt) return executable_prompt From 38f0c6981bc08f7f29333416aa2f45ac9388c465 Mon Sep 17 00:00:00 2001 From: Mengqin Shen Date: Tue, 16 Dec 2025 14:04:03 -0800 Subject: [PATCH 5/5] fix: bug in prompt.py --- .../genkit/src/genkit/blocks/prompt.py | 4 +-- .../genkit/tests/genkit/blocks/prompt_test.py | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/py/packages/genkit/src/genkit/blocks/prompt.py b/py/packages/genkit/src/genkit/blocks/prompt.py index adadc62fb1..6bd8dfe242 100644 --- a/py/packages/genkit/src/genkit/blocks/prompt.py +++ b/py/packages/genkit/src/genkit/blocks/prompt.py @@ -23,11 +23,11 @@ """ import os +import weakref from asyncio import Future from collections.abc import AsyncIterator, Callable from pathlib import Path from typing import Any -import weakref import structlog from dotpromptz.typing import ( @@ -1094,7 +1094,7 @@ def load_prompt_folder_recursively(registry: Registry, dir_path: Path, ns: str, # This is a regular prompt prefix_with_slash = f'{sub_dir}/' if sub_dir else '' load_prompt(registry, dir_path, entry.name, prefix_with_slash, ns) - elif entry.is_directory(): + elif entry.is_dir(): # Recursively process subdirectories new_sub_dir = os.path.join(sub_dir, entry.name) if sub_dir else entry.name load_prompt_folder_recursively(registry, dir_path, ns, new_sub_dir) diff --git a/py/packages/genkit/tests/genkit/blocks/prompt_test.py b/py/packages/genkit/tests/genkit/blocks/prompt_test.py index d41e570a98..2d73f48353 100644 --- a/py/packages/genkit/tests/genkit/blocks/prompt_test.py +++ b/py/packages/genkit/tests/genkit/blocks/prompt_test.py @@ -266,6 +266,35 @@ async def test_load_prompt_variant() -> None: assert 'Bob' in casual_response.text +@pytest.mark.asyncio +async def test_load_nested_prompt() -> None: + """Test loading prompts from subdirectories.""" + ai, *_ = setup_test() + + with tempfile.TemporaryDirectory() as tmpdir: + prompt_dir = Path(tmpdir) / 'prompts' + prompt_dir.mkdir() + + # Create subdirectory + sub_dir = prompt_dir / 'admin' + sub_dir.mkdir() + + # Create prompt in subdirectory + admin_prompt = sub_dir / 'dashboard.prompt' + admin_prompt.write_text('---\nmodel: echoModel\n---\nWelcome Admin {{name}}') + + load_prompt_folder(ai.registry, prompt_dir) + + # Test loading nested prompt + # Based on logic: name = "admin/dashboard" + admin_exec = await prompt(ai.registry, 'admin/dashboard') + response = await admin_exec({'name': 'SuperUser'}) + + assert 'Welcome Admin' in response.text + assert 'SuperUser' in response.text + + + @pytest.mark.asyncio async def test_load_and_use_partial() -> None: """Test loading and using partials in prompts.""" @@ -481,6 +510,7 @@ async def test_prompt_and_executable_prompt_return_types() -> None: @pytest.mark.asyncio async def test_lookup_prompt_returns_executable_prompt() -> None: """lookup_prompt should return an ExecutablePrompt that can be called.""" + ai, *_ = setup_test() with tempfile.TemporaryDirectory() as tmpdir: