Skip to content
Open
501 changes: 501 additions & 0 deletions Firebase_Genkit_Issue_3280_Analysis.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions py/packages/genkit/src/genkit/ai/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ def __init__(
plugins: list[Plugin] | None = None,
model: str | None = None,
reflection_server_spec: ServerSpec | None = None,
prompt_dir: str | None = None,
prompt_ns: str | None = None,
) -> None:
"""Initialize a new Genkit instance.

Expand All @@ -60,6 +62,13 @@ def __init__(
super().__init__()
self._initialize_server(reflection_server_spec)
self._initialize_registry(model, plugins)
# Optional, non-breaking .prompt folder load (no auto-registration)
if prompt_dir:
try:
self.registry.load_prompt_folder(prompt_dir, prompt_ns)
except Exception:
# TODO: Consider logging a warning; keep non-fatal
pass
define_generate_action(self.registry)

def run_main(self, coro: Coroutine[Any, Any, T] | None = None) -> T:
Expand Down
9 changes: 9 additions & 0 deletions py/packages/genkit/src/genkit/ai/_base_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def __init__(
plugins: list[Plugin] | None = None,
model: str | None = None,
reflection_server_spec: ServerSpec | None = None,
prompt_dir: str | None = None,
prompt_ns: str | None = None,
) -> None:
"""Initialize a new Genkit instance.

Expand All @@ -59,6 +61,13 @@ def __init__(
super().__init__()
self._reflection_server_spec = reflection_server_spec
self._initialize_registry(model, plugins)
# Optional, non-breaking .prompt folder load (no auto-registration)
if prompt_dir:
try:
self.registry.load_prompt_folder(prompt_dir, prompt_ns)
except Exception:
# TODO: Consider logging a warning; keep non-fatal
pass

def _initialize_registry(self, model: str | None, plugins: list[Plugin] | None) -> None:
"""Initialize the registry for the Genkit instance.
Expand Down
35 changes: 35 additions & 0 deletions py/packages/genkit/src/genkit/ai/_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@
ToolChoice,
)

# Optional, non-invasive helpers to load `.prompt` files via standalone handler
from genkit.dotprompt import (
load_prompt_dir as dp_load_prompt_dir,
aload_prompt_dir as dp_aload_prompt_dir,
load_prompt_file as dp_load_prompt_file,
aload_prompt_file as dp_aload_prompt_file,
)
from genkit.dotprompt.types import LoadedPrompt

EVALUATOR_METADATA_KEY_DISPLAY_NAME = 'evaluatorDisplayName'
EVALUATOR_METADATA_KEY_DEFINITION = 'evaluatorDefinition'
EVALUATOR_METADATA_KEY_IS_BILLED = 'evaluatorIsBilled'
Expand Down Expand Up @@ -103,6 +112,32 @@ def __init__(self):
"""Initialize the Genkit registry."""
self.registry: Registry = Registry()

# --- Dotprompt file-loading helpers (no registration, no side-effects) ---
def load_prompt_dir(self, dir: str, ns: str | None = None) -> dict[str, LoadedPrompt]:
"""Synchronously scan a directory and parse `.prompt` files.

Mirrors JS folder scanning behavior (partials, subdir prefixing), but does
not auto-register or render metadata.
"""
return dp_load_prompt_dir(self.registry.dotprompt, dir, ns)

async def aload_prompt_dir(self, dir: str, ns: str | None = None, *, with_metadata: bool = True) -> dict[str, LoadedPrompt]:
"""Asynchronously scan a directory and optionally render metadata."""
return await dp_aload_prompt_dir(self.registry.dotprompt, dir, ns, with_metadata=with_metadata)

def load_prompt_file(self, file_path: str, ns: str | None = None) -> LoadedPrompt:
"""Synchronously parse a single `.prompt` file (no metadata)."""
return dp_load_prompt_file(self.registry.dotprompt, file_path, ns)

async def aload_prompt_file(self, file_path: str, ns: str | None = None, *, with_metadata: bool = True) -> LoadedPrompt:
"""Asynchronously parse a single `.prompt` file and optionally render metadata."""
return await dp_aload_prompt_file(self.registry.dotprompt, file_path, ns, with_metadata=with_metadata)

# --- Lookup helpers matching JS key rules ---
def lookup_loaded_prompt(self, name: str, variant: str | None = None, ns: str | None = None):
"""Lookup a previously loaded .prompt by name/variant/ns."""
return self.registry.lookup_loaded_prompt(name, variant, ns)

def flow(self, name: str | None = None, description: str | None = None) -> Callable[[Callable], Callable]:
"""Decorator to register a function as a flow.

Expand Down
54 changes: 53 additions & 1 deletion py/packages/genkit/src/genkit/core/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@
from typing import Any

import structlog
from dotpromptz.dotprompt import Dotprompt

# Import Dotprompt optionally to keep module import-safe without the dependency.
try:
from dotpromptz.dotprompt import Dotprompt # type: ignore
except Exception: # pragma: no cover
class Dotprompt: # type: ignore
pass

from genkit.core.action import (
Action,
Expand All @@ -43,6 +49,16 @@
)
from genkit.core.action.types import ActionKind, ActionName, ActionResolver

# Optional imports for dotprompt file loading
try:
from genkit.dotprompt import load_prompt_dir as dp_load_prompt_dir # type: ignore
from genkit.dotprompt.file_loader import registry_definition_key # type: ignore
from genkit.dotprompt.types import LoadedPrompt # type: ignore
except Exception: # pragma: no cover
dp_load_prompt_dir = None # type: ignore
registry_definition_key = None # type: ignore
LoadedPrompt = object # type: ignore

logger = structlog.get_logger(__name__)

# An action store is a nested dictionary mapping ActionKind to a dictionary of
Expand Down Expand Up @@ -86,6 +102,8 @@ def __init__(self):
self._value_by_kind_and_name: dict[str, dict[str, Any]] = {}
self._lock = threading.RLock()
self.dotprompt = Dotprompt()
# Storage for prompts loaded from .prompt files (definition key -> LoadedPrompt)
self._loaded_prompts: dict[str, LoadedPrompt] = {}
# TODO: Figure out how to set this.
self.api_stability: str = 'stable'

Expand Down Expand Up @@ -271,6 +289,40 @@ def list_actions(
}
return actions

# --- Dotprompt file-based prompt management (safe, opt-in) ---
def load_prompt_folder(self, dir: str, ns: str | None = None) -> dict[str, 'LoadedPrompt']:
"""Load .prompt files into in-memory storage without registration.

This mirrors JS folder scanning behavior but intentionally avoids
registering actions. Use this to prepare for later integration.
"""
if dp_load_prompt_dir is None:
raise RuntimeError('dotprompt loader not available')
loaded = dp_load_prompt_dir(self.dotprompt, dir, ns)
with self._lock:
self._loaded_prompts.update(loaded)
return loaded

def list_loaded_prompts(self) -> list[str]:
"""Return keys of loaded .prompt definitions."""
with self._lock:
return list(self._loaded_prompts.keys())

def get_loaded_prompt(self, key: str) -> 'LoadedPrompt | None':
"""Get a previously loaded .prompt by its definition key."""
with self._lock:
return self._loaded_prompts.get(key)

def lookup_loaded_prompt(self, name: str, variant: str | None = None, ns: str | None = None) -> 'LoadedPrompt | None':
"""Lookup a loaded .prompt by name/variant/ns using JS definition key rules.

Mirrors JS `registryDefinitionKey(name, variant, ns)` composition.
"""
if registry_definition_key is None:
return None
key = registry_definition_key(name, variant, ns)
return self.get_loaded_prompt(key)

def register_value(self, kind: str, name: str, value: Any):
"""Registers a value with a given kind and name.

Expand Down
29 changes: 29 additions & 0 deletions py/packages/genkit/src/genkit/dotprompt/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Standalone .prompt file handling utilities (no registry integration)."""

from typing import Any, Callable, Dict
from dotpromptz.dotprompt import Dotprompt

from .types import LoadedPrompt, PromptFileId
from .file_loader import load_prompt_dir, load_prompt_file, registry_definition_key
from .file_loader import define_partial, define_helper
from .file_loader import (
aload_prompt_dir,
aload_prompt_file,
render_prompt_metadata,
)

__all__ = [
"LoadedPrompt",
"PromptFileId",
"registry_definition_key",
"load_prompt_dir",
"load_prompt_file",
"aload_prompt_dir",
"aload_prompt_file",
"render_prompt_metadata",
"define_partial",
"define_helper",
"Dotprompt",
]


157 changes: 157 additions & 0 deletions py/packages/genkit/src/genkit/dotprompt/file_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
from __future__ import annotations

import os
from pathlib import Path
from typing import Any, Dict, Iterable, Tuple
from dotpromptz.dotprompt import Dotprompt

from .types import LoadedPrompt, PromptFileId


# TODO: Confirm canonical namespace rules when scanning nested directories.
def registry_definition_key(name: str, variant: str | None = None, ns: str | None = None) -> str:
"""Build a definition key "ns/name.variant" where ns/variant are optional."""
prefix = f"{ns}/" if ns else ""
suffix = f".{variant}" if variant else ""
return f"{prefix}{name}{suffix}"


def _parse_name_and_variant(filename: str) -> Tuple[str, str | None]:
"""Extract base name and optional variant from a `.prompt` filename.

Behavior:
- strip `.prompt`
- if remaining contains a `.` split name and variant at the first dot
"""
base = filename[:-7] if filename.endswith('.prompt') else filename
if '.' in base:
parts = base.split('.')
return parts[0], parts[1]
return base, None


def define_partial(dp: Dotprompt, name: str, source: str) -> None:
"""Register a Handlebars partial with the provided `Dotprompt` instance."""
# Support both camelCase and snake_case for Python bindings.
if hasattr(dp, 'definePartial'):
getattr(dp, 'definePartial')(name, source) # type: ignore[attr-defined]
else:
getattr(dp, 'define_partial')(name, source)


def define_helper(dp: Dotprompt, name: str, fn: Any) -> None:
"""Register a helper on the provided `Dotprompt` instance."""
dp.defineHelper(name, fn)


def load_prompt_file(dp: Dotprompt, file_path: str, ns: str | None = None) -> LoadedPrompt:
"""Load and parse a single `.prompt` file using dotpromptz.

- Reads file as UTF-8
- Parses source via `dp.parse`
- Does NOT eagerly compile; compilation can be done by caller
- Returns a LoadedPrompt instance
"""
path = Path(file_path)
source = path.read_text(encoding='utf-8')
template = dp.parse(source)
name, variant = _parse_name_and_variant(path.name)
return LoadedPrompt(
id=PromptFileId(name=name, variant=variant, ns=ns),
template=template,
source=source,
)


async def render_prompt_metadata(dp: Dotprompt, loaded: LoadedPrompt) -> dict[str, Any]:
"""Render metadata for a parsed template using dotpromptz.

Performs cleanup for null schema descriptions.
"""
# Support both camelCase and snake_case for Python bindings.
if hasattr(dp, 'renderMetadata'):
metadata: dict[str, Any] = await getattr(dp, 'renderMetadata')(loaded.template) # type: ignore[attr-defined]
else:
metadata = await getattr(dp, 'render_metadata')(loaded.template)

# Remove null descriptions
try:
if metadata.get('output', {}).get('schema', {}).get('description', None) is None:
metadata['output']['schema'].pop('description', None)
except Exception:
pass
try:
if metadata.get('input', {}).get('schema', {}).get('description', None) is None:
metadata['input']['schema'].pop('description', None)
except Exception:
pass

loaded.metadata = metadata
return metadata


def _iter_prompt_dir(dir_path: str) -> Iterable[Tuple[Path, str]]:
"""Yield (path, subdir) for files under dir recursively.

subdir is the relative directory from the root, used for namespacing.
"""
root = Path(dir_path).resolve()
for current_dir, _dirs, files in os.walk(root):
rel = os.path.relpath(current_dir, root)
subdir = '' if rel == '.' else rel
for fname in files:
if fname.endswith('.prompt'):
yield Path(current_dir) / fname, subdir


def load_prompt_dir(dp: Dotprompt, dir_path: str, ns: str | None = None) -> Dict[str, LoadedPrompt]:
"""Recursively scan a directory, registering partials and loading prompts.

- Files starting with `_` are treated as partials; register via definePartial
- Other `.prompt` files are parsed and returned
- If a file is in a subdirectory, that subdirectory is prefixed to the prompt name
using the definition key semantics ("ns/subdir/name.variant")

Returns a dict mapping definition keys to `LoadedPrompt`.
"""
loaded: Dict[str, LoadedPrompt] = {}
for file_path, subdir in _iter_prompt_dir(dir_path):
fname = file_path.name
parent = file_path.parent
if fname.startswith('_') and fname.endswith('.prompt'):
partial_name = fname[1:-7]
define_partial(dp, partial_name, (parent / fname).read_text(encoding='utf-8'))
continue

# Regular prompt file
name, variant = _parse_name_and_variant(fname)

# Include subdir in the prompt "name" prefix, not in ns.
name_with_prefix = f"{subdir}/{name}" if subdir else name

loaded_prompt = load_prompt_file(dp, str(file_path), ns=ns)
# Update the id.name to include the subdir prefix.
loaded_prompt.id = PromptFileId(name=name_with_prefix, variant=variant, ns=ns)

key = registry_definition_key(name_with_prefix, variant, ns)
loaded[key] = loaded_prompt
return loaded


async def aload_prompt_file(dp: Dotprompt, file_path: str, ns: str | None = None, *, with_metadata: bool = True) -> LoadedPrompt:
"""Async variant that also renders metadata when requested."""
loaded = load_prompt_file(dp, file_path, ns)
if with_metadata:
await render_prompt_metadata(dp, loaded)
return loaded


async def aload_prompt_dir(dp: Dotprompt, dir_path: str, ns: str | None = None, *, with_metadata: bool = True) -> Dict[str, LoadedPrompt]:
"""Async directory loader that optionally renders metadata for each prompt."""
loaded = load_prompt_dir(dp, dir_path, ns)
if with_metadata:
for key, prompt in loaded.items():
await render_prompt_metadata(dp, prompt)
return loaded


26 changes: 26 additions & 0 deletions py/packages/genkit/src/genkit/dotprompt/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Optional


@dataclass(frozen=True)
class PromptFileId:
"""Represents a unique identifier for a prompt file."""

name: str
variant: Optional[str] = None
ns: Optional[str] = None


@dataclass
class LoadedPrompt:
"""A parsed and compiled prompt."""

id: PromptFileId
template: Any
source: str
compiled: Any | None = None
metadata: dict[str, Any] | None = None


Loading