Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions py/packages/genkit/src/genkit/_core/_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,33 @@ def extract_action_args_and_types(
# =============================================================================


GENKIT_DYNAMIC_ACTION_PROVIDER_ATTR = '_genkit_dynamic_action_provider'


def parse_dap_qualified_name(name: str) -> tuple[str, str, str] | None:
"""Parse DAP-qualified segment ``provider:innerKind/innerName``.

Used when the action key kind is ``dynamic-action-provider`` and the name
references a nested action exposed by a provider (e.g. MCP tools).

Returns:
``(provider_name, inner_kind, inner_name)`` if the string matches the
pattern; otherwise ``None`` so callers can treat the name as a plain
dynamic-action-provider id.
"""
if ':' not in name or '/' not in name:
return None
colon = name.index(':')
provider = name[:colon]
rest = name[colon + 1 :]
if '/' not in rest:
return None
inner_kind, inner_name = rest.split('/', 1)
if not provider or not inner_kind or not inner_name:
return None
return (provider, inner_kind, inner_name)


def parse_action_key(key: str) -> tuple[ActionKind, str]:
"""Parse '/<kind>/<name>' key into (ActionKind, name)."""
tokens = key.split('/')
Expand Down
10 changes: 8 additions & 2 deletions py/packages/genkit/src/genkit/_core/_dap.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
from collections.abc import Awaitable, Callable, Mapping
from typing import Any

from genkit._core._action import Action, ActionKind
from genkit._core._action import (
GENKIT_DYNAMIC_ACTION_PROVIDER_ATTR,
Action,
ActionKind,
)
from genkit._core._registry import Registry

ActionMetadataLike = Mapping[str, object]
Expand Down Expand Up @@ -151,4 +155,6 @@ async def dap_action(input: DapMetadata) -> DapMetadata:
metadata={**(metadata or {}), 'type': 'dynamic-action-provider'},
)

return DynamicActionProvider(action, fn, cache_ttl_millis)
dap = DynamicActionProvider(action, fn, cache_ttl_millis)
setattr(action, GENKIT_DYNAMIC_ACTION_PROVIDER_ATTR, dap)
return dap
70 changes: 59 additions & 11 deletions py/packages/genkit/src/genkit/_core/_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@
from typing_extensions import Never, TypeVar

from genkit._core._action import (
GENKIT_DYNAMIC_ACTION_PROVIDER_ATTR,
Action,
ActionKind,
ActionMetadata,
ActionName,
ActionRunContext,
SpanAttributeValue,
parse_action_key,
parse_dap_qualified_name,
)
from genkit._core._logger import get_logger
from genkit._core._model import (
Expand Down Expand Up @@ -448,15 +450,30 @@ async def resolve_action(self, kind: ActionKind, name: str) -> Action | None:
else:
providers_dict = {}
providers = list(providers_dict.values())
for provider in providers:
for provider_action in providers:
dap = getattr(provider_action, GENKIT_DYNAMIC_ACTION_PROVIDER_ATTR, None)
if dap is not None:
try:
resolved = await dap.get_action(str(kind), name)
if resolved is not None:
self.register_action_instance(resolved)
return await self._trigger_lazy_loading(resolved)
except Exception as e:
logger.debug(
f'Dynamic action provider {provider_action.name} failed for {kind}/{name}',
exc_info=e,
)
continue
continue
try:
response = await provider.run({'kind': kind, 'name': name})
if response.response:
self.register_action_instance(response.response)
return await self._trigger_lazy_loading(response.response)
response = await provider_action.run({'kind': kind, 'name': name})
legacy = response.response
if isinstance(legacy, Action):
self.register_action_instance(legacy)
return await self._trigger_lazy_loading(legacy)
except Exception as e:
logger.debug(
f'Dynamic action provider {provider.name} failed for {kind}/{name}',
f'Dynamic action provider {provider_action.name} failed for {kind}/{name}',
exc_info=e,
)
continue
Expand All @@ -466,20 +483,51 @@ async def resolve_action(self, kind: ActionKind, name: str) -> Action | None:
async def resolve_action_by_key(self, key: str) -> Action | None:
"""Resolve an action using its combined key string.

The key format is `<kind>/<name>`, where kind must be a valid
`ActionKind` and name may be prefixed with plugin namespace or unprefixed.
The key format is ``/<kind>/<name>``, where kind must be a valid
``ActionKind`` and name may be prefixed with plugin namespace or
unprefixed.

For nested actions exposed by a dynamic action provider, use
``/dynamic-action-provider/<provider>:<innerKind>/<innerName>`` (for
example ``/dynamic-action-provider/my-mcp:tool/echo``).

Args:
key: The action key in the format `<kind>/<name>`.
key: The action key in the format ``/<kind>/<name>``.

Returns:
The `Action` instance if found, None otherwise.
The ``Action`` instance if found, None otherwise.

Raises:
ValueError: If the key format is invalid, the kind is not a valid
`ActionKind`, or an unprefixed name is ambiguous.
``ActionKind``, or an unprefixed name is ambiguous.
"""
kind, name = parse_action_key(key)
if kind == ActionKind.DYNAMIC_ACTION_PROVIDER:
dap_parts = parse_dap_qualified_name(name)
if dap_parts is not None:
provider_name, inner_kind_str, inner_name = dap_parts
provider_action = await self.resolve_action(
ActionKind.DYNAMIC_ACTION_PROVIDER,
provider_name,
)
if provider_action is None:
return None
dap = getattr(provider_action, GENKIT_DYNAMIC_ACTION_PROVIDER_ATTR, None)
if dap is None:
return None
try:
resolved = await dap.get_action(inner_kind_str, inner_name)
except Exception as e:
logger.debug(
f'Dynamic action provider {provider_name} failed for '
f'qualified key {inner_kind_str}/{inner_name}',
exc_info=e,
)
return None
if resolved is None:
return None
self.register_action_instance(resolved)
return await self._trigger_lazy_loading(resolved)
return await self.resolve_action(kind, name)

async def list_actions(self, allowed_kinds: list[ActionKind] | None = None) -> list[ActionMetadata]:
Expand Down
10 changes: 10 additions & 0 deletions py/packages/genkit/tests/genkit/core/action_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
ActionRunContext,
create_action_key,
parse_action_key,
parse_dap_qualified_name,
parse_plugin_name_from_action_name,
)
from genkit._core._error import GenkitError
Expand Down Expand Up @@ -72,6 +73,15 @@ def test_parse_action_key_invalid_format() -> None:
parse_action_key(key)


def test_parse_dap_qualified_name() -> None:
"""Parse provider:innerKind/innerName segments."""
assert parse_dap_qualified_name('my-dap:tool/echo') == ('my-dap', 'tool', 'echo')
assert parse_dap_qualified_name('plugin/foo:model/bar') == ('plugin/foo', 'model', 'bar')
assert parse_dap_qualified_name('plain-name') is None
assert parse_dap_qualified_name('no-slash:toolonly') is None
assert parse_dap_qualified_name(':tool/x') is None


def test_create_action_key() -> None:
"""Create action key."""
assert create_action_key(ActionKind.CUSTOM, 'foo') == '/custom/foo'
Expand Down
49 changes: 49 additions & 0 deletions py/packages/genkit/tests/genkit/core/registry_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from genkit import Genkit, Plugin
from genkit._core._action import Action, ActionKind, ActionMetadata
from genkit._core._dap import DapValue, define_dynamic_action_provider
from genkit._core._registry import Registry


Expand Down Expand Up @@ -54,6 +55,54 @@ async def test_resolve_action_by_key_invalid_format() -> None:
await registry.resolve_action_by_key('invalid_key')


@pytest.mark.asyncio
async def test_resolve_action_via_dynamic_action_provider() -> None:
"""Registry resolves actions supplied only by a DAP via get_action."""
registry = Registry()

async def tool_fn(x: str) -> str:
return x

inner = Action(
name='inner-tool',
kind=ActionKind.TOOL,
fn=tool_fn,
metadata={'name': 'inner-tool'},
)

async def dap_fn() -> DapValue:
return {'tool': [inner]}

define_dynamic_action_provider(registry, 'my-dap', dap_fn)

got = await registry.resolve_action(ActionKind.TOOL, 'inner-tool')
assert got is inner


@pytest.mark.asyncio
async def test_resolve_action_by_key_dap_qualified() -> None:
"""DAP-qualified keys resolve nested actions."""
registry = Registry()

async def tool_fn(x: str) -> str:
return x

inner = Action(
name='inner-tool',
kind=ActionKind.TOOL,
fn=tool_fn,
metadata={'name': 'inner-tool'},
)

async def dap_fn() -> DapValue:
return {'tool': [inner]}

define_dynamic_action_provider(registry, 'my-dap', dap_fn)

got = await registry.resolve_action_by_key('/dynamic-action-provider/my-dap:tool/inner-tool')
assert got is inner


@pytest.mark.asyncio
async def test_resolve_action_from_plugin() -> None:
"""Resolve action from plugin test."""
Expand Down
Loading