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.
@@ -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