Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 163 additions & 22 deletions py/packages/genkit/src/genkit/ai/_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
| `'indexer'` | Indexer |
| `'model'` | Model |
| `'prompt'` | Prompt |
| `'resource'` | Resource |
| `'retriever'` | Retriever |
| `'text-llm'` | Text LLM |
| `'tool'` | Tool |
Expand All @@ -55,6 +56,12 @@
define_helper,
define_prompt,
lookup_prompt,
registry_definition_key,
to_generate_request,
)
from genkit.blocks.resource import (
ResourceContent,
matches_uri_template,
)
from genkit.blocks.retriever import IndexerFn, RetrieverFn
from genkit.blocks.tools import ToolRunContext
Expand All @@ -69,6 +76,8 @@
EvalRequest,
EvalResponse,
EvalStatusEnum,
GenerateActionOptions,
GenerateRequest,
GenerationCommonConfig,
Message,
ModelInfo,
Expand Down Expand Up @@ -573,6 +582,7 @@ def define_format(self, format: FormatDef) -> None:

def define_prompt(
self,
name: str | None = None,
variant: str | None = None,
model: str | None = None,
config: GenerationCommonConfig | dict[str, Any] | None = None,
Expand All @@ -598,31 +608,34 @@ def define_prompt(
"""Define a prompt.

Args:
variant: Optional variant name for the prompt.
model: Optional model name to use for the prompt.
config: Optional configuration for the model.
description: Optional description for the prompt.
input_schema: Optional schema for the input to the prompt.
system: Optional system message for the prompt.
prompt: Optional prompt for the model.
messages: Optional messages for the model.
output_format: Optional output format for the prompt.
output_content_type: Optional output content type for the prompt.
output_instructions: Optional output instructions for the prompt.
output_schema: Optional schema for the output from the prompt.
output_constrained: Optional flag indicating whether the output
should be constrained.
max_turns: Optional maximum number of turns for the prompt.
return_tool_requests: Optional flag indicating whether tool requests
should be returned.
metadata: Optional metadata for the prompt.
tools: Optional list of tools to use for the prompt.
tool_choice: Optional tool choice for the prompt.
use: Optional list of model middlewares to use for the prompt.
name: The name of the prompt.
variant: The variant of the prompt.
model: The model to use for generation.
config: The generation configuration.
description: A description of the prompt.
input_schema: The input schema for the prompt.
system: The system message for the prompt.
prompt: The user prompt.
messages: A list of messages to include in the prompt.
output_format: The output format.
output_content_type: The output content type.
output_instructions: Instructions for formatting the output.
output_schema: The output schema.
output_constrained: Whether the output should be constrained to the output schema.
max_turns: The maximum number of turns in a conversation.
return_tool_requests: Whether to return tool requests.
metadata: Metadata to associate with the prompt.
tools: A list of tool names to use with the prompt.
tool_choice: The tool choice strategy.
use: A list of model middlewares to apply.

Returns:
An ExecutablePrompt instance.
"""
return define_prompt(
executable_prompt = define_prompt(
self.registry,
variant=variant,
_name=name,
model=model,
config=config,
description=description,
Expand All @@ -643,6 +656,50 @@ def define_prompt(
use=use,
)

if name:
# Register actions for kind PROMPT and EXECUTABLE_PROMPT
# This allows discovery by MCP and Dev UI

async def prompt_action_fn(input: Any = None) -> GenerateRequest:
"""PROMPT action function - renders prompt and returns GenerateRequest."""
options = await executable_prompt.render(input=input)
return await to_generate_request(self.registry, options)

async def executable_prompt_action_fn(input: Any = None) -> GenerateActionOptions:
"""EXECUTABLE_PROMPT action function - renders prompt and returns GenerateActionOptions."""
return await executable_prompt.render(input=input)

action_name = registry_definition_key(name, variant)
action_metadata = {
'type': 'prompt',
'lazy': False,
'source': 'programmatic',
'prompt': {
'name': name,
'variant': variant or '',
},
}

# Register the PROMPT action
prompt_action = self.registry.register_action(
kind=ActionKind.PROMPT,
name=action_name,
fn=prompt_action_fn,
metadata=action_metadata,
)
executable_prompt._prompt_action = prompt_action
prompt_action._executable_prompt = executable_prompt
Comment on lines +690 to +691
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These direct assignments create a strong circular reference between prompt_action and executable_prompt. While Python's garbage collector can often handle cycles, it's best practice to avoid them to prevent potential memory leaks, especially in long-running applications. I recommend using a weak reference to break the cycle.

You can do this by adding import weakref at the top of the file and then changing line 691 to:

prompt_action._executable_prompt = weakref.ref(executable_prompt)

Note that any code that accesses _executable_prompt will then need to call it as a function to get the object: prompt_action._executable_prompt().


# Register the EXECUTABLE_PROMPT action
self.registry.register_action(
kind=ActionKind.EXECUTABLE_PROMPT,
name=action_name,
fn=executable_prompt_action_fn,
metadata=action_metadata,
)

return executable_prompt

async def prompt(
self,
name: str,
Expand Down Expand Up @@ -675,6 +732,90 @@ async def prompt(
variant=variant,
)

def define_resource(
self,
name: str,
fn: Callable,
uri: str | None = None,
template: str | None = None,
description: str | None = None,
metadata: dict[str, Any] | None = None,
) -> Action:
"""Define a resource action.

Resources provide content that can be accessed via URI. They can have:
- A fixed URI (e.g., "my://resource")
- A URI template with placeholders (e.g., "file://{path}")

Args:
name: Name of the resource.
fn: Function implementing the resource behavior. Should accept a dict
with 'uri' key and return ResourceContent or dict with 'content' key.
uri: Optional fixed URI for the resource.
template: Optional URI template with {param} placeholders.
description: Optional description for the resource.
metadata: Optional metadata for the resource.

Returns:
The registered Action for the resource.

Raises:
ValueError: If neither uri nor template is provided.

Examples:
# Fixed URI resource
ai.define_resource(
name="my_resource",
uri="my://resource",
fn=lambda req: {"content": [{"text": "resource content"}]}
)

# Template URI resource
ai.define_resource(
name="file",
template="file://{path}",
fn=lambda req: {"content": [{"text": f"contents of {req['uri']}"}]}
)
"""
if not uri and not template:
raise ValueError("Either 'uri' or 'template' must be provided for a resource")

resource_meta = metadata if metadata else {}
if 'resource' not in resource_meta:
resource_meta['resource'] = {}

# Store URI or template in metadata
if uri:
resource_meta['resource']['uri'] = uri
if template:
resource_meta['resource']['template'] = template

resource_description = get_func_description(fn, description)

# Wrap the resource function to handle template matching and extraction
async def resource_wrapper(input_data: dict[str, Any]) -> ResourceContent:
req_uri = input_data.get('uri')
if template and req_uri:
# Extract parameters from URI based on template
params = matches_uri_template(template, req_uri)
if params:
# Merge extracted parameters into the request data
# This allows the resource function to access them as req['param_name']
input_data = {**params, **input_data}

result = fn(input_data)
if inspect.isawaitable(result):
return await result
return result

return self.registry.register_action(
name=name,
kind=ActionKind.RESOURCE,
fn=resource_wrapper,
metadata=resource_meta,
description=resource_description,
)


class FlowWrapper:
"""A wapper for flow functions to add `stream` method."""
Expand Down
4 changes: 3 additions & 1 deletion py/packages/genkit/src/genkit/blocks/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ async def as_tool(self) -> Action:
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().',
message='Prompt name not available. This prompt was not created via Genkit.define_prompt() or load_prompt().',
)

lookup_key = registry_lookup_key(self._name, self._variant, self._ns)
Expand All @@ -366,6 +366,7 @@ async def as_tool(self) -> Action:
def define_prompt(
registry: Registry,
variant: str | None = None,
_name: str | None = None,
model: str | None = None,
config: GenerationCommonConfig | dict[str, Any] | None = None,
description: str | None = None,
Expand Down Expand Up @@ -435,6 +436,7 @@ def define_prompt(
tools=tools,
tool_choice=tool_choice,
use=use,
_name=_name,
)


Expand Down
90 changes: 90 additions & 0 deletions py/packages/genkit/src/genkit/blocks/resource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# 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

"""Resource types and functions for Genkit."""

import re
from collections.abc import Awaitable, Callable
from typing import Any

from pydantic import BaseModel

from genkit.core.typing import Part


class ResourceOptions(BaseModel):
"""Options for defining a resource.

Attributes:
name: The name of the resource.
uri: Optional fixed URI for the resource (e.g., "my://resource").
template: Optional URI template with placeholders (e.g., "file://{path}").
description: Optional description of the resource.
"""

name: str
uri: str | None = None
template: str | None = None
description: str | None = None


class ResourceContent(BaseModel):
"""Content returned by a resource.

Attributes:
content: List of content parts (text, media, etc.).
"""

content: list[Part]


# Type for resource function
ResourceFn = Callable[[dict[str, Any]], Awaitable[ResourceContent] | ResourceContent]


def matches_uri_template(template: str, uri: str) -> dict[str, str] | None:
"""Check if a URI matches a template and extract parameters.

Args:
template: URI template with {param} placeholders (e.g., "file://{path}").
uri: The URI to match against the template.

Returns:
Dictionary of extracted parameters if match, None otherwise.

Examples:
>>> matches_uri_template('file://{path}', 'file:///home/user/doc.txt')
{'path': '/home/user/doc.txt'}
>>> matches_uri_template('user://{id}/profile', 'user://123/profile')
{'id': '123'}
"""
# Split template into parts: text and {param} placeholders
parts = re.split(r'(\{[\w]+\})', template)
pattern_parts = []
for part in parts:
if part.startswith('{') and part.endswith('}'):
param_name = part[1:-1]
# Use .+? (non-greedy) to match parameters
pattern_parts.append(f'(?P<{param_name}>.+?)')
else:
pattern_parts.append(re.escape(part))

pattern = f'^{"".join(pattern_parts)}$'

match = re.match(pattern, uri)
if match:
return match.groupdict()
return None
1 change: 1 addition & 0 deletions py/packages/genkit/src/genkit/core/action/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class ActionKind(StrEnum):
MODEL = 'model'
PROMPT = 'prompt'
RERANKER = 'reranker'
RESOURCE = 'resource'
RETRIEVER = 'retriever'
TOOL = 'tool'
UTIL = 'util'
Expand Down
Loading
Loading