azurefunctions-agents-runtime 0.0.0a2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- azure_functions_agents/__init__.py +51 -0
- azure_functions_agents/_blob_history.py +368 -0
- azure_functions_agents/_credential.py +79 -0
- azure_functions_agents/_function_tool.py +97 -0
- azure_functions_agents/_logger.py +14 -0
- azure_functions_agents/app.py +97 -0
- azure_functions_agents/client_manager.py +243 -0
- azure_functions_agents/config/__init__.py +78 -0
- azure_functions_agents/config/env.py +102 -0
- azure_functions_agents/config/loader.py +109 -0
- azure_functions_agents/config/merge.py +146 -0
- azure_functions_agents/config/paths.py +42 -0
- azure_functions_agents/config/schema.py +162 -0
- azure_functions_agents/config/validation.py +109 -0
- azure_functions_agents/discovery/__init__.py +0 -0
- azure_functions_agents/discovery/mcp.py +196 -0
- azure_functions_agents/discovery/skills.py +102 -0
- azure_functions_agents/discovery/tools.py +133 -0
- azure_functions_agents/public/index.html +1523 -0
- azure_functions_agents/registration/__init__.py +13 -0
- azure_functions_agents/registration/_handlers.py +351 -0
- azure_functions_agents/registration/_naming.py +84 -0
- azure_functions_agents/registration/capabilities.py +70 -0
- azure_functions_agents/registration/endpoints.py +373 -0
- azure_functions_agents/registration/triggers.py +151 -0
- azure_functions_agents/runner.py +611 -0
- azure_functions_agents/system_tools/__init__.py +0 -0
- azure_functions_agents/system_tools/sandbox.py +357 -0
- azurefunctions_agents_runtime-0.0.0a2.dist-info/METADATA +510 -0
- azurefunctions_agents_runtime-0.0.0a2.dist-info/RECORD +33 -0
- azurefunctions_agents_runtime-0.0.0a2.dist-info/WHEEL +5 -0
- azurefunctions_agents_runtime-0.0.0a2.dist-info/licenses/LICENSE.md +21 -0
- azurefunctions_agents_runtime-0.0.0a2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Azure Functions agent runtime — public API.
|
|
2
|
+
|
|
3
|
+
This package builds Azure Functions apps backed by the Microsoft Agent
|
|
4
|
+
Framework. The most common entry points are:
|
|
5
|
+
|
|
6
|
+
* :func:`create_function_app` — top-level factory used in ``function_app.py``.
|
|
7
|
+
* :func:`run_agent` / :func:`run_agent_stream` — execute prompts directly
|
|
8
|
+
(e.g. from custom code or tests).
|
|
9
|
+
* :class:`ClientManager` — extension point for plugging in alternate chat
|
|
10
|
+
client providers. The default implementation is :class:`MAFClientManager`
|
|
11
|
+
(auto-detects OpenAI, Azure OpenAI, or Foundry from environment variables).
|
|
12
|
+
* :func:`tool` — decorator for registering Python functions from ``tools/*.py``
|
|
13
|
+
as agent tools.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ._function_tool import tool
|
|
17
|
+
from .app import create_function_app
|
|
18
|
+
from .client_manager import (
|
|
19
|
+
ClientManager,
|
|
20
|
+
MAFClientManager,
|
|
21
|
+
get_client_manager,
|
|
22
|
+
set_client_manager,
|
|
23
|
+
shutdown_client_manager,
|
|
24
|
+
)
|
|
25
|
+
from .config.paths import resolve_config_dir, set_app_root
|
|
26
|
+
from .runner import (
|
|
27
|
+
DEFAULT_MODEL,
|
|
28
|
+
DEFAULT_TIMEOUT,
|
|
29
|
+
AgentResult,
|
|
30
|
+
run_agent,
|
|
31
|
+
run_agent_stream,
|
|
32
|
+
)
|
|
33
|
+
from .system_tools.sandbox import create_sandbox_tools
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"DEFAULT_MODEL",
|
|
37
|
+
"DEFAULT_TIMEOUT",
|
|
38
|
+
"AgentResult",
|
|
39
|
+
"ClientManager",
|
|
40
|
+
"MAFClientManager",
|
|
41
|
+
"create_function_app",
|
|
42
|
+
"create_sandbox_tools",
|
|
43
|
+
"get_client_manager",
|
|
44
|
+
"resolve_config_dir",
|
|
45
|
+
"run_agent",
|
|
46
|
+
"run_agent_stream",
|
|
47
|
+
"set_app_root",
|
|
48
|
+
"set_client_manager",
|
|
49
|
+
"shutdown_client_manager",
|
|
50
|
+
"tool",
|
|
51
|
+
]
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Azure Blob Storage-backed :class:`HistoryProvider` for MAF agent sessions.
|
|
2
|
+
|
|
3
|
+
The Microsoft Agent Framework ships :class:`FileHistoryProvider` for local
|
|
4
|
+
disk storage and :class:`InMemoryHistoryProvider` for tests, but does not yet
|
|
5
|
+
provide a blob-backed implementation. This module fills that gap so the
|
|
6
|
+
runtime can persist multi-turn history to the same storage account that
|
|
7
|
+
Azure Functions already requires (``AzureWebJobsStorage``) — no extra
|
|
8
|
+
resources, no file-share mounts, no storage account keys, and true
|
|
9
|
+
multi-instance support.
|
|
10
|
+
|
|
11
|
+
Wire format
|
|
12
|
+
-----------
|
|
13
|
+
|
|
14
|
+
One blob per session, named ``{blob_prefix}{session_id}.jsonl`` inside a
|
|
15
|
+
single container (default: ``azure-functions-agents``). Blobs are
|
|
16
|
+
**Append Blobs**: every call to :meth:`save_messages` appends the JSON Lines
|
|
17
|
+
serialization of just the new messages from the current turn — this matches
|
|
18
|
+
the contract that MAF's :meth:`HistoryProvider.after_run` only ever passes
|
|
19
|
+
the per-turn delta (input + response messages), never the full history.
|
|
20
|
+
|
|
21
|
+
Concurrency
|
|
22
|
+
-----------
|
|
23
|
+
|
|
24
|
+
``BlobClient.append_block`` is atomic on the server side, so two Function
|
|
25
|
+
instances appending to the same session blob simultaneously cannot interleave
|
|
26
|
+
within a single block. The documented runtime contract is still
|
|
27
|
+
"one active turn per session id" — cross-instance turn ordering is the
|
|
28
|
+
caller's responsibility.
|
|
29
|
+
|
|
30
|
+
Configuration
|
|
31
|
+
-------------
|
|
32
|
+
|
|
33
|
+
The provider accepts either an Azure Storage connection string or an
|
|
34
|
+
identity-based ``(blob_service_url, credential)`` pair. Helpers in this
|
|
35
|
+
module resolve those from the standard Azure Functions
|
|
36
|
+
``AzureWebJobsStorage`` settings:
|
|
37
|
+
|
|
38
|
+
* ``AzureWebJobsStorage`` — connection string (local dev, Azurite).
|
|
39
|
+
* ``AzureWebJobsStorage__blobServiceUri`` (+ optional
|
|
40
|
+
``AzureWebJobsStorage__clientId``) — identity-based; uses
|
|
41
|
+
:class:`DefaultAzureCredential`, honoring the user-assigned client id when
|
|
42
|
+
present (matches the Bicep samples in this repo).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import asyncio
|
|
48
|
+
import contextlib
|
|
49
|
+
import hashlib
|
|
50
|
+
import json
|
|
51
|
+
import os
|
|
52
|
+
from collections.abc import Mapping, Sequence
|
|
53
|
+
from typing import Any, ClassVar
|
|
54
|
+
|
|
55
|
+
from agent_framework import HistoryProvider, Message
|
|
56
|
+
from azure.core.exceptions import ResourceExistsError, ResourceNotFoundError
|
|
57
|
+
|
|
58
|
+
from ._logger import logger
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Constants
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
DEFAULT_CONTAINER_NAME = "azure-functions-agents"
|
|
65
|
+
DEFAULT_BLOB_PREFIX = "agent-sessions/"
|
|
66
|
+
DEFAULT_SOURCE_ID = "blob_history"
|
|
67
|
+
|
|
68
|
+
_CONN_STRING_ENV = "AzureWebJobsStorage"
|
|
69
|
+
_BLOB_SERVICE_URI_ENV = "AzureWebJobsStorage__blobServiceUri"
|
|
70
|
+
_CLIENT_ID_ENV = "AzureWebJobsStorage__clientId"
|
|
71
|
+
_CONTAINER_ENV = "AZURE_FUNCTIONS_AGENTS_SESSION_CONTAINER"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Process-wide caches
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
# The BlobServiceClient is keyed by a non-secret identifier (account URL for
|
|
79
|
+
# identity-based, or a stable hash sentinel for connection strings) so that
|
|
80
|
+
# secrets never live inside dict keys that could leak into reprs / logs.
|
|
81
|
+
_SERVICE_CLIENTS: dict[str, Any] = {}
|
|
82
|
+
_SERVICE_CLIENTS_LOCK = asyncio.Lock()
|
|
83
|
+
|
|
84
|
+
# Container existence check is process-wide: we only need to create it once
|
|
85
|
+
# per (cache_key, container_name).
|
|
86
|
+
_ENSURED_CONTAINERS: set[tuple[str, str]] = set()
|
|
87
|
+
_ENSURED_CONTAINERS_LOCK = asyncio.Lock()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Provider
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class BlobHistoryProvider(HistoryProvider):
|
|
96
|
+
"""Append-blob-backed :class:`HistoryProvider`.
|
|
97
|
+
|
|
98
|
+
Each session is stored as a single Append Blob named
|
|
99
|
+
``{blob_prefix}{session_id}.jsonl``. Messages are written as JSON Lines —
|
|
100
|
+
one ``Message.to_dict()`` payload per line.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
DEFAULT_SOURCE_ID: ClassVar[str] = DEFAULT_SOURCE_ID
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
connection_string: str | None = None,
|
|
109
|
+
blob_service_url: str | None = None,
|
|
110
|
+
credential: Any | None = None,
|
|
111
|
+
container_name: str = DEFAULT_CONTAINER_NAME,
|
|
112
|
+
blob_prefix: str = DEFAULT_BLOB_PREFIX,
|
|
113
|
+
source_id: str = DEFAULT_SOURCE_ID,
|
|
114
|
+
load_messages: bool = True,
|
|
115
|
+
store_inputs: bool = True,
|
|
116
|
+
store_context_messages: bool = False,
|
|
117
|
+
store_context_from: set[str] | None = None,
|
|
118
|
+
store_outputs: bool = True,
|
|
119
|
+
skip_excluded: bool = False,
|
|
120
|
+
) -> None:
|
|
121
|
+
super().__init__(
|
|
122
|
+
source_id=source_id,
|
|
123
|
+
load_messages=load_messages,
|
|
124
|
+
store_inputs=store_inputs,
|
|
125
|
+
store_context_messages=store_context_messages,
|
|
126
|
+
store_context_from=store_context_from,
|
|
127
|
+
store_outputs=store_outputs,
|
|
128
|
+
)
|
|
129
|
+
if not connection_string and not blob_service_url:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"BlobHistoryProvider requires either 'connection_string' or 'blob_service_url'."
|
|
132
|
+
)
|
|
133
|
+
self.skip_excluded = skip_excluded
|
|
134
|
+
self._connection_string = connection_string
|
|
135
|
+
self._blob_service_url = blob_service_url
|
|
136
|
+
self._credential = credential
|
|
137
|
+
self._container_name = container_name
|
|
138
|
+
self._blob_prefix = _normalize_prefix(blob_prefix)
|
|
139
|
+
self._cache_key = _service_client_cache_key(
|
|
140
|
+
connection_string=connection_string,
|
|
141
|
+
blob_service_url=blob_service_url,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
# MAF HistoryProvider surface
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
async def get_messages(
|
|
149
|
+
self,
|
|
150
|
+
session_id: str | None,
|
|
151
|
+
*,
|
|
152
|
+
state: dict[str, Any] | None = None,
|
|
153
|
+
**kwargs: Any,
|
|
154
|
+
) -> list[Message]:
|
|
155
|
+
del state, kwargs
|
|
156
|
+
blob_client = await self._get_blob_client(session_id)
|
|
157
|
+
try:
|
|
158
|
+
downloader = await blob_client.download_blob(encoding="utf-8")
|
|
159
|
+
content = await downloader.readall()
|
|
160
|
+
except ResourceNotFoundError:
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
text = content if isinstance(content, str) else content.decode("utf-8")
|
|
164
|
+
messages: list[Message] = []
|
|
165
|
+
for line_number, raw in enumerate(text.splitlines(), start=1):
|
|
166
|
+
line = raw.strip()
|
|
167
|
+
if not line:
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
payload = json.loads(line)
|
|
171
|
+
except ValueError as exc:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"Failed to deserialize history line {line_number} from blob "
|
|
174
|
+
f"'{self._container_name}/{self._blob_name(session_id)}'."
|
|
175
|
+
) from exc
|
|
176
|
+
if not isinstance(payload, Mapping):
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"History line {line_number} in blob "
|
|
179
|
+
f"'{self._container_name}/{self._blob_name(session_id)}' "
|
|
180
|
+
"did not deserialize to a mapping."
|
|
181
|
+
)
|
|
182
|
+
messages.append(Message.from_dict(dict(payload)))
|
|
183
|
+
|
|
184
|
+
if self.skip_excluded:
|
|
185
|
+
messages = [
|
|
186
|
+
m for m in messages if not m.additional_properties.get("_excluded", False)
|
|
187
|
+
]
|
|
188
|
+
return messages
|
|
189
|
+
|
|
190
|
+
async def save_messages(
|
|
191
|
+
self,
|
|
192
|
+
session_id: str | None,
|
|
193
|
+
messages: Sequence[Message],
|
|
194
|
+
*,
|
|
195
|
+
state: dict[str, Any] | None = None,
|
|
196
|
+
**kwargs: Any,
|
|
197
|
+
) -> None:
|
|
198
|
+
del state, kwargs
|
|
199
|
+
if not messages:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
payload = "".join(f"{_serialize_message(message)}\n" for message in messages)
|
|
203
|
+
data = payload.encode("utf-8")
|
|
204
|
+
|
|
205
|
+
blob_client = await self._get_blob_client(session_id)
|
|
206
|
+
try:
|
|
207
|
+
await blob_client.append_block(data)
|
|
208
|
+
return
|
|
209
|
+
except ResourceNotFoundError:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
# Blob does not exist yet — create it and retry. Suppress the
|
|
213
|
+
# "already exists" race that happens when another instance wins
|
|
214
|
+
# the create.
|
|
215
|
+
with contextlib.suppress(ResourceExistsError):
|
|
216
|
+
await blob_client.create_append_blob()
|
|
217
|
+
await blob_client.append_block(data)
|
|
218
|
+
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
# Internals
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def _blob_name(self, session_id: str | None) -> str:
|
|
224
|
+
stem = session_id or "default"
|
|
225
|
+
return f"{self._blob_prefix}{stem}.jsonl"
|
|
226
|
+
|
|
227
|
+
async def _get_blob_client(self, session_id: str | None) -> Any:
|
|
228
|
+
service_client = await self._get_service_client()
|
|
229
|
+
await self._ensure_container(service_client)
|
|
230
|
+
return service_client.get_blob_client(
|
|
231
|
+
container=self._container_name,
|
|
232
|
+
blob=self._blob_name(session_id),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def _get_service_client(self) -> Any:
|
|
236
|
+
cached = _SERVICE_CLIENTS.get(self._cache_key)
|
|
237
|
+
if cached is not None:
|
|
238
|
+
return cached
|
|
239
|
+
async with _SERVICE_CLIENTS_LOCK:
|
|
240
|
+
cached = _SERVICE_CLIENTS.get(self._cache_key)
|
|
241
|
+
if cached is not None:
|
|
242
|
+
return cached
|
|
243
|
+
client = _build_service_client(
|
|
244
|
+
connection_string=self._connection_string,
|
|
245
|
+
blob_service_url=self._blob_service_url,
|
|
246
|
+
credential=self._credential,
|
|
247
|
+
)
|
|
248
|
+
_SERVICE_CLIENTS[self._cache_key] = client
|
|
249
|
+
return client
|
|
250
|
+
|
|
251
|
+
async def _ensure_container(self, service_client: Any) -> None:
|
|
252
|
+
key = (self._cache_key, self._container_name)
|
|
253
|
+
if key in _ENSURED_CONTAINERS:
|
|
254
|
+
return
|
|
255
|
+
async with _ENSURED_CONTAINERS_LOCK:
|
|
256
|
+
if key in _ENSURED_CONTAINERS:
|
|
257
|
+
return
|
|
258
|
+
container_client = service_client.get_container_client(self._container_name)
|
|
259
|
+
with contextlib.suppress(ResourceExistsError):
|
|
260
|
+
await container_client.create_container()
|
|
261
|
+
_ENSURED_CONTAINERS.add(key)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# Module-level helpers
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _serialize_message(message: Message) -> str:
|
|
270
|
+
payload = message.to_dict()
|
|
271
|
+
serialized = json.dumps(payload)
|
|
272
|
+
if "\n" in serialized or "\r" in serialized:
|
|
273
|
+
raise ValueError("Serialized message must not contain newline characters for JSONL.")
|
|
274
|
+
return serialized
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _normalize_prefix(prefix: str) -> str:
|
|
278
|
+
"""Strip leading slashes and ensure exactly one trailing slash if non-empty."""
|
|
279
|
+
cleaned = (prefix or "").lstrip("/")
|
|
280
|
+
if cleaned and not cleaned.endswith("/"):
|
|
281
|
+
cleaned = f"{cleaned}/"
|
|
282
|
+
return cleaned
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _service_client_cache_key(
|
|
286
|
+
*,
|
|
287
|
+
connection_string: str | None,
|
|
288
|
+
blob_service_url: str | None,
|
|
289
|
+
) -> str:
|
|
290
|
+
"""Build a non-secret cache key for the :class:`BlobServiceClient` cache.
|
|
291
|
+
|
|
292
|
+
We intentionally avoid storing the raw connection string as a dict key so
|
|
293
|
+
that secrets do not surface in error reprs or debug dumps. A short hash
|
|
294
|
+
is sufficient because we only need stable equality for the cache.
|
|
295
|
+
"""
|
|
296
|
+
if blob_service_url:
|
|
297
|
+
return f"url::{blob_service_url}"
|
|
298
|
+
assert connection_string is not None
|
|
299
|
+
digest = hashlib.sha256(connection_string.encode("utf-8")).hexdigest()[:32]
|
|
300
|
+
return f"conn::{digest}"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _build_service_client(
|
|
304
|
+
*,
|
|
305
|
+
connection_string: str | None,
|
|
306
|
+
blob_service_url: str | None,
|
|
307
|
+
credential: Any | None,
|
|
308
|
+
) -> Any:
|
|
309
|
+
from azure.storage.blob.aio import BlobServiceClient
|
|
310
|
+
|
|
311
|
+
if connection_string:
|
|
312
|
+
return BlobServiceClient.from_connection_string(connection_string)
|
|
313
|
+
assert blob_service_url is not None
|
|
314
|
+
if credential is None:
|
|
315
|
+
from ._credential import build_async_credential_with_client_id
|
|
316
|
+
|
|
317
|
+
# Precedence: storage-specific identity (AzureWebJobsStorage__clientId) wins,
|
|
318
|
+
# then app-wide AZURE_CLIENT_ID, then bare DefaultAzureCredential().
|
|
319
|
+
client_id = (
|
|
320
|
+
os.environ.get(_CLIENT_ID_ENV) or os.environ.get("AZURE_CLIENT_ID") or ""
|
|
321
|
+
).strip()
|
|
322
|
+
credential = build_async_credential_with_client_id(client_id)
|
|
323
|
+
return BlobServiceClient(account_url=blob_service_url, credential=credential)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# Factory used by the runner
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def build_blob_provider_from_environment(
|
|
332
|
+
*,
|
|
333
|
+
container_name: str | None = None,
|
|
334
|
+
) -> BlobHistoryProvider | None:
|
|
335
|
+
"""Construct a :class:`BlobHistoryProvider` from ``AzureWebJobsStorage`` env vars.
|
|
336
|
+
|
|
337
|
+
Returns ``None`` if neither a connection string nor a blob service URI is
|
|
338
|
+
configured. Honors both the connection-string form (used locally with
|
|
339
|
+
Azurite) and the identity-based form
|
|
340
|
+
(``AzureWebJobsStorage__blobServiceUri``) that Azure Functions deploys
|
|
341
|
+
use with managed identity.
|
|
342
|
+
"""
|
|
343
|
+
conn = (os.environ.get(_CONN_STRING_ENV) or "").strip()
|
|
344
|
+
uri = (os.environ.get(_BLOB_SERVICE_URI_ENV) or "").strip()
|
|
345
|
+
if not conn and not uri:
|
|
346
|
+
return None
|
|
347
|
+
container = container_name or (os.environ.get(_CONTAINER_ENV) or "").strip() or None
|
|
348
|
+
kwargs: dict[str, Any] = {}
|
|
349
|
+
if container:
|
|
350
|
+
kwargs["container_name"] = container
|
|
351
|
+
if conn:
|
|
352
|
+
logger.info(
|
|
353
|
+
"BlobHistoryProvider: using AzureWebJobsStorage connection string (container=%s).",
|
|
354
|
+
container or DEFAULT_CONTAINER_NAME,
|
|
355
|
+
)
|
|
356
|
+
return BlobHistoryProvider(connection_string=conn, **kwargs)
|
|
357
|
+
logger.info(
|
|
358
|
+
"BlobHistoryProvider: using AzureWebJobsStorage__blobServiceUri=%s (container=%s).",
|
|
359
|
+
uri,
|
|
360
|
+
container or DEFAULT_CONTAINER_NAME,
|
|
361
|
+
)
|
|
362
|
+
return BlobHistoryProvider(blob_service_url=uri, **kwargs)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def reset_caches_for_testing() -> None:
|
|
366
|
+
"""Drop the module-level caches. Test-only helper."""
|
|
367
|
+
_SERVICE_CLIENTS.clear()
|
|
368
|
+
_ENSURED_CONTAINERS.clear()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Shared ``DefaultAzureCredential`` builders that honor ``AZURE_CLIENT_ID``.
|
|
2
|
+
|
|
3
|
+
Multi-identity Function Apps need an explicit ``managed_identity_client_id``;
|
|
4
|
+
without one, :class:`DefaultAzureCredential` picks a managed identity
|
|
5
|
+
non-deterministically when more than one is assigned. These helpers centralize
|
|
6
|
+
the ``AZURE_CLIENT_ID`` lookup so every component (the MAF client manager,
|
|
7
|
+
the ACA sandbox, the ARM/connector data-plane clients) selects the same
|
|
8
|
+
identity in the same way.
|
|
9
|
+
|
|
10
|
+
``BlobHistoryProvider`` deliberately does **not** use these helpers because it
|
|
11
|
+
follows a storage-specific precedence (``AzureWebJobsStorage__clientId`` wins,
|
|
12
|
+
then falls back to ``AZURE_CLIENT_ID``); see
|
|
13
|
+
:mod:`azure_functions_agents._blob_history`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from azure.identity import DefaultAzureCredential as SyncDefaultAzureCredential
|
|
23
|
+
from azure.identity.aio import DefaultAzureCredential as AsyncDefaultAzureCredential
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_AZURE_CLIENT_ID_ENV = "AZURE_CLIENT_ID"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_credential() -> SyncDefaultAzureCredential:
|
|
30
|
+
"""Return a sync ``DefaultAzureCredential`` honoring ``AZURE_CLIENT_ID``."""
|
|
31
|
+
from azure.identity import DefaultAzureCredential
|
|
32
|
+
|
|
33
|
+
client_id = os.environ.get(_AZURE_CLIENT_ID_ENV)
|
|
34
|
+
if client_id:
|
|
35
|
+
return DefaultAzureCredential(managed_identity_client_id=client_id)
|
|
36
|
+
return DefaultAzureCredential()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_credential_with_client_id(
|
|
40
|
+
client_id: str | None,
|
|
41
|
+
) -> SyncDefaultAzureCredential:
|
|
42
|
+
"""Return a sync ``DefaultAzureCredential`` for a caller-supplied client id.
|
|
43
|
+
|
|
44
|
+
Pass an empty/``None`` value to fall back to a bare
|
|
45
|
+
:class:`DefaultAzureCredential`.
|
|
46
|
+
"""
|
|
47
|
+
from azure.identity import DefaultAzureCredential
|
|
48
|
+
|
|
49
|
+
if client_id:
|
|
50
|
+
return DefaultAzureCredential(managed_identity_client_id=client_id)
|
|
51
|
+
return DefaultAzureCredential()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_async_credential() -> AsyncDefaultAzureCredential:
|
|
55
|
+
"""Return an async ``DefaultAzureCredential`` honoring ``AZURE_CLIENT_ID``."""
|
|
56
|
+
from azure.identity.aio import DefaultAzureCredential
|
|
57
|
+
|
|
58
|
+
client_id = os.environ.get(_AZURE_CLIENT_ID_ENV)
|
|
59
|
+
if client_id:
|
|
60
|
+
return DefaultAzureCredential(managed_identity_client_id=client_id)
|
|
61
|
+
return DefaultAzureCredential()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_async_credential_with_client_id(
|
|
65
|
+
client_id: str | None,
|
|
66
|
+
) -> AsyncDefaultAzureCredential:
|
|
67
|
+
"""Return an async ``DefaultAzureCredential`` for a caller-supplied client id.
|
|
68
|
+
|
|
69
|
+
Use this when the calling module has its own precedence rules for which
|
|
70
|
+
environment variable identifies the managed identity (e.g.
|
|
71
|
+
:class:`BlobHistoryProvider` prefers ``AzureWebJobsStorage__clientId`` and
|
|
72
|
+
only falls back to ``AZURE_CLIENT_ID``). Pass an empty/``None`` value to
|
|
73
|
+
fall back to a bare :class:`DefaultAzureCredential`.
|
|
74
|
+
"""
|
|
75
|
+
from azure.identity.aio import DefaultAzureCredential
|
|
76
|
+
|
|
77
|
+
if client_id:
|
|
78
|
+
return DefaultAzureCredential(managed_identity_client_id=client_id)
|
|
79
|
+
return DefaultAzureCredential()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Any, TypeVar, overload
|
|
7
|
+
|
|
8
|
+
from agent_framework import FunctionTool
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
__all__ = ["FunctionTool", "tool"]
|
|
12
|
+
|
|
13
|
+
SchemaT = TypeVar("SchemaT", bound=BaseModel)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _wrap_with_schema( # noqa: UP047
|
|
17
|
+
func: Callable[[SchemaT], Any],
|
|
18
|
+
schema: type[SchemaT],
|
|
19
|
+
) -> Callable[..., Awaitable[Any]]:
|
|
20
|
+
@wraps(func)
|
|
21
|
+
async def wrapper(**kwargs: Any) -> Any:
|
|
22
|
+
params = schema(**kwargs)
|
|
23
|
+
result = func(params)
|
|
24
|
+
if inspect.isawaitable(result):
|
|
25
|
+
return await result
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
return wrapper
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@overload
|
|
32
|
+
def tool(
|
|
33
|
+
func: Callable[..., Any],
|
|
34
|
+
*,
|
|
35
|
+
name: str | None = None,
|
|
36
|
+
description: str | None = None,
|
|
37
|
+
schema: None = None,
|
|
38
|
+
**kwargs: Any,
|
|
39
|
+
) -> FunctionTool: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@overload
|
|
43
|
+
def tool( # noqa: UP047
|
|
44
|
+
func: Callable[[SchemaT], Any],
|
|
45
|
+
*,
|
|
46
|
+
name: str | None = None,
|
|
47
|
+
description: str | None = None,
|
|
48
|
+
schema: type[SchemaT],
|
|
49
|
+
**kwargs: Any,
|
|
50
|
+
) -> FunctionTool: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@overload
|
|
54
|
+
def tool(
|
|
55
|
+
*,
|
|
56
|
+
name: str | None = None,
|
|
57
|
+
description: str | None = None,
|
|
58
|
+
schema: None = None,
|
|
59
|
+
**kwargs: Any,
|
|
60
|
+
) -> Callable[[Callable[..., Any]], FunctionTool]: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@overload
|
|
64
|
+
def tool( # noqa: UP047
|
|
65
|
+
*,
|
|
66
|
+
name: str | None = None,
|
|
67
|
+
description: str | None = None,
|
|
68
|
+
schema: type[SchemaT],
|
|
69
|
+
**kwargs: Any,
|
|
70
|
+
) -> Callable[[Callable[[SchemaT], Any]], FunctionTool]: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tool(
|
|
74
|
+
func: Callable[..., Any] | None = None,
|
|
75
|
+
*,
|
|
76
|
+
name: str | None = None,
|
|
77
|
+
description: str | None = None,
|
|
78
|
+
schema: type[BaseModel] | None = None,
|
|
79
|
+
**kwargs: Any,
|
|
80
|
+
) -> FunctionTool | Callable[[Callable[..., Any]], FunctionTool]:
|
|
81
|
+
def decorator(inner: Callable[..., Any]) -> FunctionTool:
|
|
82
|
+
wrapped: Callable[..., Any] = inner
|
|
83
|
+
input_model: type[BaseModel] | None = None
|
|
84
|
+
if schema is not None:
|
|
85
|
+
wrapped = _wrap_with_schema(inner, schema)
|
|
86
|
+
input_model = schema
|
|
87
|
+
return FunctionTool(
|
|
88
|
+
name=name or inner.__name__,
|
|
89
|
+
description=(description or inner.__doc__ or "").strip(),
|
|
90
|
+
func=wrapped,
|
|
91
|
+
input_model=input_model,
|
|
92
|
+
**kwargs,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if func is not None:
|
|
96
|
+
return decorator(func)
|
|
97
|
+
return decorator
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Shared logger for the Azure Functions Agent Runtime.
|
|
2
|
+
|
|
3
|
+
All modules in this package use this single logger so that operators can
|
|
4
|
+
filter or configure the entire runtime's output in one place.
|
|
5
|
+
|
|
6
|
+
In Application Insights the ``customDimensions.Category`` field will be
|
|
7
|
+
``azure.functions.AgentRuntime`` — consistent with the ``azure.functions.*``
|
|
8
|
+
naming convention used by the Azure Functions Python SDK (e.g.
|
|
9
|
+
``azure.functions.AsgiMiddleware``).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("azure.functions.AgentRuntime")
|