Files
semantic-kernel/python/semantic_kernel/orchestration/delegate_inference.py
T
John Liu 37f2760fdd Python: Fix SKFunction.invoke_stream_async (#3986)
### Motivation and Context

<!-- Thank you for your contribution to the semantic-kernel repo!
Please help reviewers and future users, providing the following
information:
  1. Why is this change required?
  2. What problem does it solve?
  3. What scenario does it contribute to?
  4. If it fixes an open issue, please link to the issue here.
-->

The `invoke_stream_async` method on a native `SKFunction` is not
functional. The following sample code should work:
```python
import asyncio

import semantic_kernel as sk
from semantic_kernel.skill_definition import sk_function


class StreamNativeSkill:
    @sk_function(name="stream")
    async def stream(self, input: str) -> None: # This should also work: -> AsyncGenerator
        for i in range(5):
            await asyncio.sleep(1)
            yield input + str(i)


async def main():
    kernel = sk.Kernel()

    skill = kernel.import_skill(StreamNativeSkill(), "stream_skill")
    native_stream_func = skill["stream"]

    async for chunk in native_stream_func.invoke_stream_async("HELLO!"):
        print(chunk)


if __name__ == "__main__":
    asyncio.run(main())
```

And should output:
```
HELLO!0
HELLO!1
HELLO!2
HELLO!3
HELLO!4
```

But now it only shows:
```
HELLO!
```

### Description

<!-- Describe your changes, the overall approach, the underlying design.
These notes will help understanding how your code works. Thanks! -->

Add four new `DelegateTypes` for native asynchronous generator functions
for different types of parameters:
- OutAsyncGenerator: No parameter, has `yield`
- InStringOutAsyncGenerator: One `str` parameter, has `yield`
- InContextOutAsyncGenerator: One `SKContext` parameter, has `yield`
- InStringAndContextOutAsyncGenerator: Both `str` and `SKContext`
parameters, has `yield`

Along with these four new types, add four new `DelegateInference`
functions to detect the types:
- infer_out_async_generator
- infer_in_string_out_async_generator
- infer_in_context_out_async_generator
- infer_in_string_and_context_out_async_generator

And because the async generator functions needs to use
`inspect.isasyncgenfunction` function to check instead of
`inspect.iscoroutinefunction`, add another parameter `is_asyncgenfunc`
to all the infer functions.

And finally, fix `SKFunction._invoke_native_stream_async` so that it can
correctly iterating from an async generator function.

### Contribution Checklist

<!-- Before submitting this PR, please make sure: -->

- [ ] The code builds clean without any errors or warnings
- [ ] The PR follows the [SK Contribution
Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md)
and the [pre-submission formatting
script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts)
raises no violations
- [ ] All unit tests pass, and I have added new tests where possible
- [ ] I didn't break anyone 😄
2023-12-08 14:48:11 +00:00

337 lines
12 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
from inspect import Signature, isasyncgenfunction, iscoroutinefunction, signature
from typing import NoReturn
from semantic_kernel.kernel_exception import KernelException
from semantic_kernel.orchestration.delegate_types import DelegateTypes
from semantic_kernel.sk_pydantic import SKBaseModel
def _infers(delegate_type):
def decorator(function):
function._delegate_type = delegate_type
return function
return decorator
def _is_annotation_of_type(annotation, type_to_match) -> bool:
return (annotation is type_to_match) or (
# Handle cases where the annotation is provided as a string to avoid circular imports
# for example: `async def read_async(self, context: "SKContext"):` in file_io_skill.py
isinstance(annotation, str)
and annotation == type_to_match.__name__
)
def _has_no_params(signature: Signature) -> bool:
return len(signature.parameters) == 0
def _return_is_str(signature: Signature) -> bool:
return signature.return_annotation is str
def _return_is_context(signature: Signature) -> bool:
from semantic_kernel.orchestration.sk_context import SKContext
return _is_annotation_of_type(signature.return_annotation, SKContext)
def _return_is_none(signature: Signature) -> bool:
return signature.return_annotation is None
def _no_return(signature: Signature) -> bool:
return signature.return_annotation is Signature.empty
def _has_first_param_with_type(
signature: Signature, annotation, only: bool = True
) -> bool:
if len(signature.parameters) < 1:
return False
if only and len(signature.parameters) != 1:
return False
first_param = list(signature.parameters.values())[0]
return _is_annotation_of_type(first_param.annotation, annotation)
def _has_two_params_second_is_context(signature: Signature) -> bool:
from semantic_kernel.orchestration.sk_context import SKContext
if len(signature.parameters) < 2:
return False
second_param = list(signature.parameters.values())[1]
return _is_annotation_of_type(second_param.annotation, SKContext)
def _first_param_is_str(signature: Signature, only: bool = True) -> bool:
return _has_first_param_with_type(signature, str, only)
def _first_param_is_context(signature: Signature) -> bool:
from semantic_kernel.orchestration.sk_context import SKContext
return _has_first_param_with_type(signature, SKContext)
class DelegateInference(SKBaseModel):
@staticmethod
@_infers(DelegateTypes.Void)
def infer_void(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _has_no_params(signature)
matches = matches and _return_is_none(signature)
matches = matches and not awaitable and not is_asyncgenfunc
return matches
@staticmethod
@_infers(DelegateTypes.OutString)
def infer_out_string(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _has_no_params(signature)
matches = matches and _return_is_str(signature)
matches = matches and not awaitable and not is_asyncgenfunc
return matches
@staticmethod
@_infers(DelegateTypes.OutTaskString)
def infer_out_task_string(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _has_no_params(signature)
matches = matches and _return_is_str(signature)
matches = matches and awaitable
return matches
@staticmethod
@_infers(DelegateTypes.InSKContext)
def infer_in_sk_context(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_context(signature)
matches = matches and _return_is_none(signature)
matches = matches and not awaitable and not is_asyncgenfunc
return matches
@staticmethod
@_infers(DelegateTypes.InSKContextOutString)
def infer_in_sk_context_out_string(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_context(signature)
matches = matches and _return_is_str(signature)
matches = matches and not awaitable and not is_asyncgenfunc
return matches
@staticmethod
@_infers(DelegateTypes.InSKContextOutTaskString)
def infer_in_sk_context_out_task_string(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_context(signature)
matches = matches and _return_is_str(signature)
matches = matches and awaitable
return matches
@staticmethod
@_infers(DelegateTypes.ContextSwitchInSKContextOutTaskSKContext)
def infer_context_switch_in_sk_context_out_task_sk_context(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_context(signature)
matches = matches and _return_is_context(signature)
matches = matches and awaitable
return matches
@staticmethod
@_infers(DelegateTypes.InString)
def infer_in_string(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_str(signature)
matches = matches and _return_is_none(signature)
matches = matches and not awaitable and not is_asyncgenfunc
return matches
@staticmethod
@_infers(DelegateTypes.InStringOutString)
def infer_in_string_out_string(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_str(signature)
matches = matches and _return_is_str(signature)
matches = matches and not awaitable and not is_asyncgenfunc
return matches
@staticmethod
@_infers(DelegateTypes.InStringOutTaskString)
def infer_in_string_out_task_string(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_str(signature)
matches = matches and _return_is_str(signature)
matches = matches and awaitable
return matches
@staticmethod
@_infers(DelegateTypes.InStringAndContext)
def infer_in_string_and_context(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_str(signature, only=False)
matches = matches and _has_two_params_second_is_context(signature)
matches = matches and _return_is_none(signature)
matches = matches and not awaitable and not is_asyncgenfunc
return matches
@staticmethod
@_infers(DelegateTypes.InStringAndContextOutString)
def infer_in_string_and_context_out_string(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_str(signature, only=False)
matches = matches and _has_two_params_second_is_context(signature)
matches = matches and _return_is_str(signature)
matches = matches and not awaitable and not is_asyncgenfunc
return matches
@staticmethod
@_infers(DelegateTypes.InStringAndContextOutTaskString)
def infer_in_string_and_context_out_task_string(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_str(signature, only=False)
matches = matches and _has_two_params_second_is_context(signature)
matches = matches and _return_is_str(signature)
matches = matches and awaitable
return matches
@staticmethod
@_infers(DelegateTypes.ContextSwitchInStringAndContextOutTaskContext)
def infer_context_switch_in_string_and_context_out_task_context(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_str(signature, only=False)
matches = matches and _has_two_params_second_is_context(signature)
matches = matches and _return_is_context(signature)
matches = matches and awaitable
return matches
@staticmethod
@_infers(DelegateTypes.InStringOutTask)
def infer_in_string_out_task(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_str(signature)
matches = matches and _return_is_none(signature)
matches = matches and awaitable
return matches
@staticmethod
@_infers(DelegateTypes.InContextOutTask)
def infer_in_context_out_task(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_context(signature)
matches = matches and _return_is_none(signature)
matches = matches and awaitable
return matches
@staticmethod
@_infers(DelegateTypes.InStringAndContextOutTask)
def infer_in_string_and_context_out_task(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_str(signature, only=False)
matches = matches and _has_two_params_second_is_context(signature)
matches = matches and _return_is_none(signature)
matches = matches and awaitable
return matches
@staticmethod
@_infers(DelegateTypes.OutTask)
def infer_out_task(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _has_no_params(signature)
matches = matches and awaitable
return matches
@staticmethod
@_infers(DelegateTypes.OutAsyncGenerator)
def infer_out_async_generator(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _has_no_params(signature)
matches = matches and is_asyncgenfunc
return matches
@staticmethod
@_infers(DelegateTypes.InStringOutAsyncGenerator)
def infer_in_string_out_async_generator(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_str(signature)
matches = matches and is_asyncgenfunc
return matches
@staticmethod
@_infers(DelegateTypes.InContextOutAsyncGenerator)
def infer_in_context_out_async_generator(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_context(signature)
matches = matches and is_asyncgenfunc
return matches
@staticmethod
@_infers(DelegateTypes.InStringAndContextOutAsyncGenerator)
def infer_in_string_and_context_out_async_generator(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> bool:
matches = _first_param_is_str(signature, only=False)
matches = matches and _has_two_params_second_is_context(signature)
matches = matches and is_asyncgenfunc
return matches
@staticmethod
@_infers(DelegateTypes.Unknown)
def infer_unknown(
signature: Signature, awaitable: bool, is_asyncgenfunc: bool
) -> NoReturn:
raise KernelException(
KernelException.ErrorCodes.FunctionTypeNotSupported,
"Invalid function type detected, unable to infer DelegateType."
+ f" Function: {signature}",
)
@staticmethod
def infer_delegate_type(function) -> DelegateTypes:
# Get the function signature
function_signature = signature(function)
if _no_return(function_signature):
raise KernelException(
KernelException.ErrorCodes.FunctionTypeNotSupported,
"No return type specified, unable to infer DelegateType.",
)
awaitable = iscoroutinefunction(function)
is_asyncgenfunc = isasyncgenfunction(function)
for name, value in DelegateInference.__dict__.items():
wrapped = getattr(value, "__wrapped__", getattr(value, "__func__", None))
if name.startswith("infer_") and hasattr(wrapped, "_delegate_type"):
# Get the delegate type
if wrapped(function_signature, awaitable, is_asyncgenfunc):
return wrapped._delegate_type
return DelegateTypes.Unknown