Skip to content

Commit 51bb7d2

Browse files
feat(py): implement resource support (#4204)
Co-authored-by: Mengqin Shen <mengqin@google.com>
1 parent b067e14 commit 51bb7d2

File tree

10 files changed

+318
-7
lines changed

10 files changed

+318
-7
lines changed

py/bin/sanitize_schema_typing.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,55 @@ def visit_ClassDef(self, node: ast.ClassDef) -> Any:
233233
# For other classes, just copy the rest of the body
234234
new_body.extend(node.body[body_start_index:])
235235

236+
# PYTHON EXTENSION: Add resources field to GenerateActionOptions
237+
if node.name == 'GenerateActionOptions':
238+
self._inject_resources_field(new_body)
239+
236240
node.body = cast(list[ast.stmt], new_body)
237241
return node
238242

243+
def _inject_resources_field(self, body: list[ast.stmt | ast.Constant | ast.Assign]) -> None:
244+
"""Inject resources field after tools field in GenerateActionOptions.
245+
246+
This adds the resources field to match the JS SDK implementation without
247+
modifying the shared schema file. The JS SDK manually adds this field in
248+
model-types.ts line 398.
249+
"""
250+
251+
tools_index = -1
252+
for i, stmt in enumerate(body):
253+
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
254+
if stmt.target.id == 'tools':
255+
tools_index = i
256+
break
257+
258+
if tools_index == -1:
259+
return # tools field not found, skip injection
260+
261+
# Check if resources field already exists
262+
for stmt in body:
263+
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
264+
if stmt.target.id == 'resources':
265+
return # Already exists, don't add again
266+
267+
# Create the resources field: resources: list[str] | None = None
268+
resources_field = ast.AnnAssign(
269+
target=ast.Name(id='resources', ctx=ast.Store()),
270+
annotation=ast.BinOp(
271+
left=ast.Subscript(
272+
value=ast.Name(id='list', ctx=ast.Load()), slice=ast.Name(id='str', ctx=ast.Load()), ctx=ast.Load()
273+
),
274+
op=ast.BitOr(),
275+
right=ast.Constant(value=None),
276+
),
277+
value=ast.Constant(value=None),
278+
simple=1,
279+
)
280+
281+
# Insert after tools field
282+
body.insert(tools_index + 1, resources_field)
283+
self.modified = True
284+
239285

240286
def add_header(content: str) -> str:
241287
"""Add the generated header to the content."""

py/packages/genkit/src/genkit/blocks/generate.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,15 @@
2929
MessageWrapper,
3030
ModelMiddleware,
3131
)
32+
from genkit.blocks.resource import ResourceInput, find_matching_resource, resolve_resources
3233
from genkit.blocks.tools import ToolInterruptError
3334
from genkit.codec import dump_dict
3435
from genkit.core.action import ActionRunContext
3536
from genkit.core.error import GenkitError, StatusName
3637
from genkit.core.registry import Action, ActionKind, Registry
3738
from genkit.core.typing import (
39+
DocumentData,
40+
DocumentPart,
3841
GenerateActionOptions,
3942
GenerateRequest,
4043
GenerateResponse,
@@ -101,6 +104,9 @@ async def generate_action(
101104

102105
raw_request, formatter = apply_format(raw_request, format_def)
103106

107+
if raw_request.resources:
108+
raw_request = await apply_resources(registry, raw_request)
109+
104110
assert_valid_tool_names(tools)
105111

106112
(
@@ -412,6 +418,131 @@ def apply_transfer_preamble(next_request: GenerateActionOptions, preamble: Gener
412418
return next_request
413419

414420

421+
def _extract_resource_uri(resource_obj: Any) -> str | None:
422+
"""Extract URI from a resource object.
423+
424+
Handles various Pydantic wrapper structures (Resource, Resource1, RootModel, dict).
425+
426+
Args:
427+
resource_obj: The resource object to extract URI from.
428+
429+
Returns:
430+
The extracted URI string, or None if not found.
431+
"""
432+
# Direct uri attribute (Resource1, ResourceInput, etc.)
433+
if hasattr(resource_obj, 'uri'):
434+
return resource_obj.uri
435+
436+
# Unwrap RootModel structures
437+
if hasattr(resource_obj, 'root'):
438+
return _extract_resource_uri(resource_obj.root)
439+
440+
# Unwrap nested resource attribute
441+
if hasattr(resource_obj, 'resource'):
442+
return _extract_resource_uri(resource_obj.resource)
443+
444+
# Handle dict representation
445+
if isinstance(resource_obj, dict) and 'uri' in resource_obj:
446+
return resource_obj['uri']
447+
448+
return None
449+
450+
451+
async def apply_resources(registry: Registry, raw_request: GenerateActionOptions) -> GenerateActionOptions:
452+
"""Applies resources to the request messages by hydrating resource parts.
453+
454+
Args:
455+
registry: The registry to use for resolving resources.
456+
raw_request: The generation request.
457+
458+
Returns:
459+
The updated generation request with hydrated resources.
460+
"""
461+
# Quick check if any message has a resource part
462+
has_resource = False
463+
for msg in raw_request.messages:
464+
for part in msg.content:
465+
if part.root.resource:
466+
has_resource = True
467+
break
468+
if has_resource:
469+
break
470+
471+
if not has_resource:
472+
return raw_request
473+
474+
# Resolve all declared resources
475+
resources = []
476+
if raw_request.resources:
477+
resources = await resolve_resources(registry, raw_request.resources)
478+
479+
updated_messages = []
480+
for msg in raw_request.messages:
481+
if not any(p.root.resource for p in msg.content):
482+
updated_messages.append(msg)
483+
continue
484+
485+
updated_content = []
486+
for part in msg.content:
487+
if not part.root.resource:
488+
updated_content.append(part)
489+
continue
490+
491+
resource_obj = part.root.resource
492+
493+
# Extract URI from the resource object
494+
# The resource can be wrapped in various Pydantic structures (Resource, Resource1, etc.)
495+
ref_uri = _extract_resource_uri(resource_obj)
496+
if not ref_uri:
497+
logger.warning(
498+
f'Unable to extract URI from resource part: {type(resource_obj).__name__}. '
499+
f'Resource part will be skipped.'
500+
)
501+
continue
502+
503+
# Find matching resource action
504+
if not resources:
505+
raise GenkitError(
506+
status='NOT_FOUND',
507+
message=f'failed to find matching resource for {ref_uri}',
508+
)
509+
510+
from genkit.blocks.resource import ResourceInput, find_matching_resource
511+
512+
# Normalize to ResourceInput for matching
513+
resource_input = ResourceInput(uri=ref_uri)
514+
resource_action = await find_matching_resource(registry, resources, resource_input)
515+
516+
if not resource_action:
517+
raise GenkitError(
518+
status='NOT_FOUND',
519+
message=f'failed to find matching resource for {ref_uri}',
520+
)
521+
522+
# Execute the resource
523+
# Create a simple context for the resource execution
524+
resource_ctx = ActionRunContext(on_chunk=None, context=None)
525+
response = await resource_action.arun(resource_input, resource_ctx)
526+
527+
# response.response is ResourceOutput which has .content (list of Parts)
528+
# It usually returns a dict if coming from dynamic_resource (model_dump called)
529+
output_content = None
530+
if hasattr(response.response, 'content'):
531+
output_content = response.response.content
532+
elif isinstance(response.response, dict) and 'content' in response.response:
533+
output_content = response.response['content']
534+
535+
if output_content:
536+
updated_content.extend(output_content)
537+
538+
updated_messages.append(Message(role=msg.role, content=updated_content, metadata=msg.metadata))
539+
540+
# Return a new request with updated messages
541+
new_request = raw_request.model_copy()
542+
new_request.messages = updated_messages
543+
return new_request
544+
545+
415546
def assert_valid_tool_names(raw_request: GenerateActionOptions):
416547
"""Assert that tool names in the request are valid.
417548

py/packages/genkit/src/genkit/blocks/prompt.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ class PromptConfig(BaseModel):
106106
use: list[ModelMiddleware] | None = None
107107
docs: list[DocumentData] | Callable | None = None
108108
tool_responses: list[Part] | None = None
109+
resources: list[str] | None = None
109110

110111

111112
class ExecutablePrompt:
@@ -134,6 +135,7 @@ def __init__(
134135
tool_choice: ToolChoice | None = None,
135136
use: list[ModelMiddleware] | None = None,
136137
docs: list[DocumentData] | Callable | None = None,
138+
resources: list[str] | None = None,
137139
_name: str | None = None, # prompt name for action lookup
138140
_ns: str | None = None, # namespace for action lookup
139141
_prompt_action: Action | None = None, # reference to PROMPT action
@@ -164,6 +166,7 @@ def __init__(
164166
tool_choice: The tool choice strategy.
165167
use: A list of model middlewares to apply.
166168
docs: A list of documents to be used for grounding.
169+
resources: A list of resource URIs to be used for grounding.
167170
"""
168171
self._registry = registry
169172
self._variant = variant
@@ -186,6 +189,7 @@ def __init__(
186189
self._tool_choice = tool_choice
187190
self._use = use
188191
self._docs = docs
192+
self._resources = resources
189193
self._cache_prompt = PromptCache()
190194
self._name = _name # Store name/ns for action lookup (used by as_tool())
191195
self._ns = _ns
@@ -298,6 +302,7 @@ async def render(
298302
input_schema=self._input_schema,
299303
metadata=self._metadata,
300304
docs=self._docs,
305+
resources=self._resources,
301306
)
302307

303308
model = options.model or self._registry.default_model

py/packages/genkit/src/genkit/core/action/_tracing.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,8 @@ def record_output_metadata(span, output) -> None:
5656
output: The output data returned by the action.
5757
"""
5858
span.set_attribute('genkit:state', 'success')
59-
span.set_attribute('genkit:output', dump_json(output))
59+
try:
60+
span.set_attribute('genkit:output', dump_json(output))
61+
except Exception:
62+
# Fallback for non-serializable output
63+
span.set_attribute('genkit:output', str(output))

py/packages/genkit/src/genkit/core/action/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class ActionKind(StrEnum):
4949
"""
5050

5151
CUSTOM = 'custom'
52+
DYNAMIC_ACTION_PROVIDER = 'dynamic-action-provider'
5253
EMBEDDER = 'embedder'
5354
EVALUATOR = 'evaluator'
5455
EXECUTABLE_PROMPT = 'executable-prompt'

py/packages/genkit/src/genkit/core/registry.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,29 @@ def lookup_action(self, kind: ActionKind, name: str) -> Action | None:
193193
if plugin_name and plugin_name in self._action_resolvers:
194194
self._action_resolvers[plugin_name](kind, name)
195195

196+
if (
197+
(kind not in self._entries or name not in self._entries[kind])
198+
and kind != ActionKind.DYNAMIC_ACTION_PROVIDER
199+
and ActionKind.DYNAMIC_ACTION_PROVIDER in self._entries
200+
):
201+
for provider in self._entries[ActionKind.DYNAMIC_ACTION_PROVIDER].values():
202+
try:
203+
# Construct input for the provider action
204+
input_data = {'kind': kind, 'name': name}
205+
# Execute the provider action synchronously
206+
response = provider.run(input_data)
207+
action = response.response
208+
209+
if action:
210+
self.register_action_from_instance(action)
211+
break
212+
except Exception as e:
213+
logger.debug(
214+
f'Dynamic action provider {provider.name} failed for {kind}/{name}',
215+
exc_info=e,
216+
)
217+
continue
218+
196219
if kind in self._entries and name in self._entries[kind]:
197220
return self._entries[kind][name]
198221

py/packages/genkit/src/genkit/core/typing.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -993,6 +993,7 @@ class GenerateActionOptions(BaseModel):
993993
docs: list[DocumentData] | None = None
994994
messages: list[Message]
995995
tools: list[str] | None = None
996+
resources: list[str] | None = None
996997
tool_choice: ToolChoice | None = Field(None, alias='toolChoice')
997998
config: Any | None = None
998999
output: GenerateActionOutputConfig | None = None

0 commit comments

Comments
 (0)