azurefunctions-agents-runtime 0.0.0.dev1__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 +20 -0
- azure_functions_agents/app.py +720 -0
- azure_functions_agents/arm.py +95 -0
- azure_functions_agents/client_manager.py +84 -0
- azure_functions_agents/config.py +191 -0
- azure_functions_agents/connector_tool_cache.py +124 -0
- azure_functions_agents/connector_tools.py +267 -0
- azure_functions_agents/connectors.py +460 -0
- azure_functions_agents/mcp.py +87 -0
- azure_functions_agents/public/index.html +1504 -0
- azure_functions_agents/runner.py +406 -0
- azure_functions_agents/sandbox.py +288 -0
- azure_functions_agents/skills.py +24 -0
- azure_functions_agents/tools.py +316 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/METADATA +386 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/RECORD +20 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/WHEEL +5 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/licenses/LICENSE.md +21 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/top_level.txt +2 -0
- copilot_functions/__init__.py +3 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from copilot.tools import Tool, ToolInvocation, ToolResult
|
|
9
|
+
|
|
10
|
+
from .arm import ArmClient, DataPlaneClient
|
|
11
|
+
from .connectors import ConnectionInfo, ParsedOperation, ParsedParameter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _sanitize_name(name: str) -> str:
|
|
15
|
+
"""Sanitize parameter name to match ^[a-zA-Z0-9_.-]{1,64}$."""
|
|
16
|
+
sanitized = re.sub(r"[^a-zA-Z0-9_.\-]", "_", name)
|
|
17
|
+
return sanitized[:64]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _to_snake_case(name: str) -> str:
|
|
21
|
+
"""Convert operationId to snake_case."""
|
|
22
|
+
s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
|
|
23
|
+
s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
|
|
24
|
+
s = re.sub(r"[^a-zA-Z0-9]", "_", s)
|
|
25
|
+
s = re.sub(r"_+", "_", s)
|
|
26
|
+
return s.strip("_").lower()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _param_to_json_schema(param: ParsedParameter) -> dict:
|
|
30
|
+
"""Convert a ParsedParameter to a JSON Schema property."""
|
|
31
|
+
type_map = {"integer": "integer", "number": "number", "boolean": "boolean"}
|
|
32
|
+
schema: dict = {"type": type_map.get(param.type, "string")}
|
|
33
|
+
if param.description:
|
|
34
|
+
schema["description"] = param.description
|
|
35
|
+
if param.enum:
|
|
36
|
+
schema["enum"] = param.enum
|
|
37
|
+
if param.default is not None:
|
|
38
|
+
schema["default"] = param.default
|
|
39
|
+
return schema
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _build_invoke_path(op: ParsedOperation, args: dict, all_params: list[ParsedParameter], url_encode: bool = True) -> str:
|
|
43
|
+
"""Build the invoke path by stripping /{connectionId} and substituting path params.
|
|
44
|
+
|
|
45
|
+
When url_encode is False (V1 dynamicInvoke), path param values are inserted
|
|
46
|
+
as-is since the path is a JSON field, not a real URL. When True (V2 data
|
|
47
|
+
plane), values are percent-encoded for use in an HTTP URL.
|
|
48
|
+
"""
|
|
49
|
+
path = re.sub(r"^/\{connectionId\}", "", op.path, flags=re.IGNORECASE)
|
|
50
|
+
for param in all_params:
|
|
51
|
+
if param.location == "path":
|
|
52
|
+
sanitized = _sanitize_name(param.name)
|
|
53
|
+
value = args.get(sanitized)
|
|
54
|
+
if value is None:
|
|
55
|
+
raise ValueError(f"Missing required path parameter: {param.name}")
|
|
56
|
+
replacement = quote(str(value), safe="") if url_encode else str(value)
|
|
57
|
+
path = path.replace(f"{{{param.name}}}", replacement)
|
|
58
|
+
# Substitute internal path params with their defaults
|
|
59
|
+
for param in op.internal_params:
|
|
60
|
+
if param.location == "path" and param.default is not None:
|
|
61
|
+
replacement = quote(str(param.default), safe="") if url_encode else str(param.default)
|
|
62
|
+
path = path.replace(f"{{{param.name}}}", replacement)
|
|
63
|
+
return path
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def generate_tools(
|
|
67
|
+
arm: ArmClient, connection: ConnectionInfo,
|
|
68
|
+
prefix: str | None = None,
|
|
69
|
+
data_plane_client: DataPlaneClient | None = None,
|
|
70
|
+
) -> list[Tool]:
|
|
71
|
+
"""Generate Copilot SDK Tool objects for each operation in a connection.
|
|
72
|
+
|
|
73
|
+
Tool names are ``{effective_prefix}_{api_name}_{operation_id}`` where:
|
|
74
|
+
- ``prefix`` from frontmatter overrides the default
|
|
75
|
+
- Default prefix is the connection resource name (from ARM ID)
|
|
76
|
+
- If effective_prefix == api_name, collapse to ``{api_name}_{operation_id}``
|
|
77
|
+
- Truncated to 64 chars (prefix shrinks first to preserve operation clarity)
|
|
78
|
+
"""
|
|
79
|
+
tools = []
|
|
80
|
+
api_name = connection.api_name
|
|
81
|
+
|
|
82
|
+
# Determine effective prefix
|
|
83
|
+
if prefix:
|
|
84
|
+
effective_prefix = _sanitize_name(_to_snake_case(prefix))
|
|
85
|
+
else:
|
|
86
|
+
effective_prefix = _sanitize_name(_to_snake_case(connection.name))
|
|
87
|
+
|
|
88
|
+
for op in connection.operations:
|
|
89
|
+
snake_op = _to_snake_case(op.operation_id)
|
|
90
|
+
|
|
91
|
+
# Build tool name: collapse prefix when it matches api_name
|
|
92
|
+
if effective_prefix == api_name:
|
|
93
|
+
tool_name = f"{api_name}_{snake_op}"
|
|
94
|
+
else:
|
|
95
|
+
tool_name = f"{effective_prefix}_{api_name}_{snake_op}"
|
|
96
|
+
|
|
97
|
+
# Smart truncation: shrink prefix first to preserve operation name
|
|
98
|
+
if len(tool_name) > 64:
|
|
99
|
+
suffix = f"_{api_name}_{snake_op}" if effective_prefix != api_name else f"_{snake_op}"
|
|
100
|
+
prefix_budget = 64 - len(suffix)
|
|
101
|
+
if prefix_budget > 0:
|
|
102
|
+
tool_name = f"{effective_prefix[:prefix_budget]}{suffix}"
|
|
103
|
+
else:
|
|
104
|
+
tool_name = tool_name[:64]
|
|
105
|
+
logging.warning(f"Tool name truncated to 64 chars: '{tool_name}'")
|
|
106
|
+
|
|
107
|
+
tool_name = tool_name[:64]
|
|
108
|
+
|
|
109
|
+
# Build JSON schema for parameters
|
|
110
|
+
properties: dict = {}
|
|
111
|
+
required: list[str] = []
|
|
112
|
+
all_params = op.parameters + op.body_properties
|
|
113
|
+
|
|
114
|
+
for param in op.parameters:
|
|
115
|
+
key = _sanitize_name(param.name)
|
|
116
|
+
properties[key] = _param_to_json_schema(param)
|
|
117
|
+
if param.required:
|
|
118
|
+
required.append(key)
|
|
119
|
+
|
|
120
|
+
for param in op.body_properties:
|
|
121
|
+
key = _sanitize_name(param.name)
|
|
122
|
+
properties[key] = _param_to_json_schema(param)
|
|
123
|
+
if param.required or param.name in op.body_required_fields:
|
|
124
|
+
required.append(key)
|
|
125
|
+
|
|
126
|
+
parameters_schema: dict = {"type": "object", "properties": properties}
|
|
127
|
+
if required:
|
|
128
|
+
parameters_schema["required"] = required
|
|
129
|
+
|
|
130
|
+
# Build description
|
|
131
|
+
desc_parts = [op.summary or op.operation_id]
|
|
132
|
+
if op.description and op.description != op.summary:
|
|
133
|
+
desc_parts.append(op.description)
|
|
134
|
+
desc_parts.append(f"(via {connection.display_name})")
|
|
135
|
+
if connection.status != "Connected":
|
|
136
|
+
desc_parts.append(f"Connection status: {connection.status}")
|
|
137
|
+
description = " — ".join(desc_parts)
|
|
138
|
+
|
|
139
|
+
def make_handler(op=op, connection=connection, all_params=all_params):
|
|
140
|
+
async def handler(invocation: ToolInvocation) -> ToolResult:
|
|
141
|
+
args = invocation.arguments or {}
|
|
142
|
+
|
|
143
|
+
# V2 uses direct HTTP URLs (need encoding); V1 uses JSON path field (no encoding)
|
|
144
|
+
is_v2 = bool(data_plane_client and connection.connection_runtime_url)
|
|
145
|
+
invoke_path = _build_invoke_path(op, args, all_params, url_encode=is_v2)
|
|
146
|
+
|
|
147
|
+
queries = {}
|
|
148
|
+
for param in op.parameters:
|
|
149
|
+
if param.location == "query":
|
|
150
|
+
key = _sanitize_name(param.name)
|
|
151
|
+
if key in args:
|
|
152
|
+
queries[param.name] = args[key]
|
|
153
|
+
|
|
154
|
+
# Inject internal query params with defaults
|
|
155
|
+
for param in op.internal_params:
|
|
156
|
+
if param.location == "query" and param.default is not None:
|
|
157
|
+
if param.name not in queries:
|
|
158
|
+
queries[param.name] = param.default
|
|
159
|
+
|
|
160
|
+
body = {}
|
|
161
|
+
for param in op.body_properties:
|
|
162
|
+
key = _sanitize_name(param.name)
|
|
163
|
+
if key in args:
|
|
164
|
+
value = args[key]
|
|
165
|
+
if param.type in ("object", "array") and isinstance(value, str):
|
|
166
|
+
try:
|
|
167
|
+
value = json.loads(value)
|
|
168
|
+
except (json.JSONDecodeError, ValueError):
|
|
169
|
+
pass
|
|
170
|
+
# Handle dot-separated names as nested objects
|
|
171
|
+
if "." in param.name:
|
|
172
|
+
parts = param.name.split(".", 1)
|
|
173
|
+
if parts[0] not in body:
|
|
174
|
+
body[parts[0]] = {}
|
|
175
|
+
body[parts[0]][parts[1]] = value
|
|
176
|
+
else:
|
|
177
|
+
body[param.name] = value
|
|
178
|
+
|
|
179
|
+
# Inject internal body params with defaults
|
|
180
|
+
for param in op.internal_params:
|
|
181
|
+
if param.location == "body" and param.default is not None:
|
|
182
|
+
if param.name not in body:
|
|
183
|
+
body[param.name] = param.default
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
if data_plane_client and connection.connection_runtime_url:
|
|
187
|
+
# V2: direct HTTP to data plane
|
|
188
|
+
url = f"{connection.connection_runtime_url.rstrip('/')}{invoke_path}"
|
|
189
|
+
result = await data_plane_client.request(
|
|
190
|
+
op.method,
|
|
191
|
+
url,
|
|
192
|
+
params=queries or None,
|
|
193
|
+
body=body or None,
|
|
194
|
+
)
|
|
195
|
+
return ToolResult(
|
|
196
|
+
text_result_for_llm=json.dumps(result, indent=2, default=str),
|
|
197
|
+
result_type="success",
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
# V1: dynamicInvoke via ARM
|
|
201
|
+
request_body: dict = {
|
|
202
|
+
"request": {
|
|
203
|
+
"method": op.method,
|
|
204
|
+
"path": invoke_path,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if queries:
|
|
208
|
+
request_body["request"]["queries"] = queries
|
|
209
|
+
if body:
|
|
210
|
+
request_body["request"]["body"] = body
|
|
211
|
+
|
|
212
|
+
result = await arm.post(
|
|
213
|
+
f"{connection.resource_id}/dynamicInvoke",
|
|
214
|
+
body=request_body,
|
|
215
|
+
)
|
|
216
|
+
response = result.get("response", {})
|
|
217
|
+
response_body = response.get("body", result)
|
|
218
|
+
raw_status = response.get("statusCode", 200)
|
|
219
|
+
try:
|
|
220
|
+
status_code = int(raw_status)
|
|
221
|
+
except (ValueError, TypeError):
|
|
222
|
+
# statusCode can be a string like "NotFound", "Created", etc.
|
|
223
|
+
status_str = str(raw_status).lower()
|
|
224
|
+
if status_str in ("notfound",):
|
|
225
|
+
status_code = 404
|
|
226
|
+
elif status_str in ("badrequest",):
|
|
227
|
+
status_code = 400
|
|
228
|
+
elif status_str in ("unauthorized",):
|
|
229
|
+
status_code = 401
|
|
230
|
+
elif status_str in ("forbidden",):
|
|
231
|
+
status_code = 403
|
|
232
|
+
elif status_str in ("internalservererror",):
|
|
233
|
+
status_code = 500
|
|
234
|
+
elif status_str in ("created",):
|
|
235
|
+
status_code = 201
|
|
236
|
+
elif status_str in ("ok", "accepted", "nocontent"):
|
|
237
|
+
status_code = 200
|
|
238
|
+
else:
|
|
239
|
+
status_code = 500 # unknown status, treat as error
|
|
240
|
+
|
|
241
|
+
if status_code >= 400:
|
|
242
|
+
return ToolResult(
|
|
243
|
+
text_result_for_llm=f"Error ({status_code}): {json.dumps(response_body)}",
|
|
244
|
+
result_type="error",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return ToolResult(
|
|
248
|
+
text_result_for_llm=json.dumps(response_body, indent=2, default=str),
|
|
249
|
+
result_type="success",
|
|
250
|
+
)
|
|
251
|
+
except Exception as e:
|
|
252
|
+
error_type = type(e).__name__
|
|
253
|
+
return ToolResult(
|
|
254
|
+
text_result_for_llm=f"Error invoking {op.operation_id}: {error_type}: {e}",
|
|
255
|
+
result_type="error",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return handler
|
|
259
|
+
|
|
260
|
+
tools.append(Tool(
|
|
261
|
+
name=tool_name,
|
|
262
|
+
description=description,
|
|
263
|
+
parameters=parameters_schema,
|
|
264
|
+
handler=make_handler(),
|
|
265
|
+
))
|
|
266
|
+
|
|
267
|
+
return tools
|