Setting the file. One moment.
Prompt Connect · Foundry Iq · microsoft/azure-skills · Skills Docs
ContentsBack to the top of the page File Cu Canary
def _unique_resources
— line 594
This file
Number 10.61
Position 61 of 77
Type Python
Size 69 KB
Lines 1,541 helpers/ prompt_connect.py
Python · 1,541 lines · 69 KB
11
import
sys
12 from importlib.metadata import PackageNotFoundError, version
13 from pathlib import Path
14 from typing import Any, Callable
15 from urllib.parse import parse_qs, quote, unquote, urlencode, urlsplit
16
17 try :
18 from . import _prompt_read
19 from ._bootstrap_io import MAX_BYTES , read_json, run_cli
20 from ._common import (
21 MANAGEMENT_AUDIENCE ,
22 HelperFailure,
23 TokenProvider,
24 Transport,
25 azure_cli_token,
26 blocked_result,
27 canonical_bytes,
28 digest,
29 emit_result,
30 http_request,
31 is_ambiguous_mutation_failure,
32 is_ambiguous_sdk_error,
33 load_approved_input,
34 reject_secrets,
35 require_allowed_fields,
36 sdk_error_metadata,
37 )
38 except ImportError :
39 import _prompt_read
40 from _bootstrap_io import MAX_BYTES , read_json, run_cli
41 from _common import ( # type: ignore[no-redef]
42 MANAGEMENT_AUDIENCE ,
43 HelperFailure,
44 TokenProvider,
45 Transport,
46 azure_cli_token,
47 blocked_result,
48 canonical_bytes,
49 digest,
50 emit_result,
51 http_request,
52 is_ambiguous_mutation_failure,
53 is_ambiguous_sdk_error,
54 load_approved_input,
55 reject_secrets,
56 require_allowed_fields,
57 sdk_error_metadata,
58 )
59
60
61 ARM_API_VERSION = "2025-10-01-preview"
62 SDK_MAJOR = "2"
63 GROUNDING = (
64 "For every user question, call knowledge_base_retrieve before answering, "
65 "including questions that seem unrelated to the knowledge base. "
66 "Answer only from evidence returned for that question and cite the original "
67 "sources. Do not answer from general knowledge or assume an answer without "
68 "retrieval. If the retrieved evidence does not support an answer, reply "
69 "exactly: I don't know. Do not add citations to an unsupported answer. "
70 "If retrieval fails, report the failure instead of treating it as no evidence "
71 "or answering from general knowledge."
72 )
73 PROJECT_ID = re.compile(
74 r " ^ /subscriptions/ [ ^/ ] + /resourceGroups/ [ ^/ ] + /providers/"
75 r "Microsoft \. CognitiveServices/accounts/ ( ?P<account> [ ^/ ] + ) /projects/"
76 r " ( ?P<project> [ ^/ ] + )$ " ,
77 re. IGNORECASE ,
78 )
79 PROJECT_PATH = re.compile( r " ^ /api/projects/ ( ?P<project> [ ^/ ] + ) / ? $ " )
80 SEARCH_HOST = re.compile(
81 r " ^[ a-z0-9 ](?:[ a-z0-9- ] {0,58} [ a-z0-9 ]) ? \. search \. windows \. net $ "
82 )
83 KB_MCP_PATH = re.compile( r " ^ /knowledgebases/ [ ^/ ] + /mcp $ " )
84 SHA256 = re.compile( r " ^ sha256: [ a-f0-9 ] {64} $ " )
85 PLAN_FIELDS = {
86 "operation" ,
87 "outcome" ,
88 "sdk_major" ,
89 "project_resource_id" ,
90 "project_endpoint" ,
91 "connection" ,
92 "agent" ,
93 "rbac_verified" ,
94 "allowed_tools" ,
95 "require_approval" ,
96 "permission_forwarding" ,
97 "grounding_instructions" ,
98 "network" ,
99 "owner" ,
100 "cleanup_approved" ,
101 "connection_plan_version" ,
102 "verified_dependencies" ,
103 "agent_versions" ,
104 }
105
106
107 def _project_endpoint (value: Any) -> str :
108 if not isinstance (value, str ):
109 raise HelperFailure(
110 "project-endpoint-invalid" ,
111 "Project endpoint must be a string." ,
112 blocked_at = "input-resolution" ,
113 )
114 try :
115 parsed = urlsplit(value)
116 port = parsed.port
117 except ValueError as error:
118 raise HelperFailure(
119 "project-endpoint-invalid" , "Project endpoint is malformed." ,
120 blocked_at = "input-resolution" ,
121 ) from error
122 if (
123 parsed.scheme != "https"
124 or not parsed.hostname
125 or not parsed.hostname.endswith( ".services.ai.azure.com" )
126 or PROJECT_PATH .fullmatch(parsed.path) is None
127 or parsed.query
128 or parsed.fragment
129 or parsed.username
130 or parsed.password
131 or port not in { None , 443 }
132 ):
133 raise HelperFailure(
134 "project-endpoint-invalid" ,
135 "Project endpoint must be an HTTPS services.ai.azure.com project URL." ,
136 blocked_at = "input-resolution" ,
137 )
138 return value.rstrip( "/" )
139
140
141 def _project_identity (plan: dict[ str , Any]) -> tuple[ str , str ]:
142 project_id = plan.get( "project_resource_id" )
143 match = PROJECT_ID .fullmatch(project_id) if isinstance (project_id, str ) else None
144 if match is None :
145 raise HelperFailure(
146 "project-resource-id-invalid" ,
147 "project_resource_id must identify one Microsoft Foundry project." ,
148 blocked_at = "input-resolution" ,
149 )
150 endpoint = _project_endpoint(plan.get( "project_endpoint" ))
151 parsed = urlsplit(endpoint)
152 endpoint_account = parsed.hostname.removesuffix( ".services.ai.azure.com" )
153 endpoint_match = PROJECT_PATH .fullmatch(parsed.path)
154 if (
155 endpoint_match is None
156 or endpoint_account.casefold() != match.group( "account" ).casefold()
157 or unquote(endpoint_match.group( "project" )).casefold()
158 != match.group( "project" ).casefold()
159 ):
160 raise HelperFailure(
161 "project-identity-mismatch" ,
162 "project_endpoint and project_resource_id must identify the same Foundry project." ,
163 blocked_at = "reconciliation" ,
164 )
165 return project_id, endpoint
166
167
168 def _connection_url (plan: dict[ str , Any]) -> str :
169 project_id = plan.get( "project_resource_id" )
170 if not isinstance (project_id, str ) or PROJECT_ID .fullmatch(project_id) is None :
171 raise HelperFailure(
172 "project-resource-id-invalid" ,
173 "project_resource_id must identify one Microsoft Foundry project." ,
174 blocked_at = "input-resolution" ,
175 )
176 connection = plan.get( "connection" )
177 if not isinstance (connection, dict ):
178 raise HelperFailure(
179 "connection-invalid" ,
180 "connection must be an object." ,
181 blocked_at = "input-resolution" ,
182 )
183 name = connection.get( "name" )
184 if not isinstance (name, str ) or not name:
185 raise HelperFailure(
186 "connection-invalid" ,
187 "connection.name is required." ,
188 blocked_at = "input-resolution" ,
189 )
190 return (
191 "https://management.azure.com"
192 f " { project_id } /connections/ { quote(name, safe = '' ) } ?"
193 + urlencode({ "api-version" : ARM_API_VERSION })
194 )
195
196
197 def connection_definition (plan: dict[ str , Any]) -> dict[ str , Any]:
198 connection = plan[ "connection" ]
199 return {
200 "name" : connection[ "name" ],
201 "type" : "Microsoft.CognitiveServices/accounts/projects/connections" ,
202 "properties" : {
203 "authType" : "ProjectManagedIdentity" ,
204 "category" : "RemoteTool" ,
205 "target" : connection[ "target" ],
206 "isSharedToAll" : connection.get( "is_shared_to_all" , True ),
207 "audience" : "https://search.azure.com/" ,
208 "metadata" : { "ApiType" : "Azure" },
209 },
210 }
211
212
213 def _subset (expected: Any, actual: Any) -> bool :
214 if isinstance (expected, dict ):
215 return isinstance (actual, dict ) and all (
216 key in actual and _subset(value, actual[key])
217 for key, value in expected.items()
218 )
219 if isinstance (expected, list ):
220 return (
221 isinstance (actual, list )
222 and len (expected) == len (actual)
223 and all (_subset(left, right) for left, right in zip (expected, actual))
224 )
225 return expected == actual
226
227
228 def _connection_readback (
229 plan: dict[ str , Any], actual: dict[ str , Any]
230 ) -> tuple[ bool , list[ str ]]:
231 expected = connection_definition(plan)
232 properties = actual.get( "properties" )
233 expected_id = (
234 f " { plan[ 'project_resource_id' ] } /connections/ { plan[ 'connection' ][ 'name' ] } "
235 )
236 actual_id = actual.get( "id" )
237 if (
238 actual.get( "name" ) != expected[ "name" ]
239 or (
240 "id" in actual
241 and (
242 not isinstance (actual_id, str )
243 or actual_id.casefold() != expected_id.casefold()
244 )
245 )
246 or not isinstance (properties, dict )
247 ):
248 return False , []
249 required = {
250 key: value
251 for key, value in expected[ "properties" ].items()
252 if key != "isSharedToAll"
253 }
254 if not _subset(required, properties):
255 return False , []
256
257 expected_sharing = expected[ "properties" ][ "isSharedToAll" ]
258 actual_sharing = properties.get( "isSharedToAll" )
259 if not isinstance (actual_sharing, bool ) or (
260 actual_sharing and not expected_sharing
261 ):
262 return False , []
263 if not expected_sharing and properties.get( "sharedUserList" , []) != []:
264 return False , []
265 warnings = []
266 if actual.get( "type" ) != expected[ "type" ]:
267 warnings.append(
268 "connection-type-metadata-differs: ARM type metadata differs from the "
269 "Foundry project type; the exact resource path and binding were checked."
270 )
271 if expected_sharing and not actual_sharing:
272 warnings.append(
273 "connection-sharing-restricted: requested isSharedToAll=true but Azure "
274 "returned false; retained without widening access. Agent invocation "
275 "is required to verify usability."
276 )
277 return True , warnings
278
279
280 def connection_leaf (value: Any) -> str :
281 return str (value or "" ).rstrip( "/" ).rsplit( "/" , 1 )[ - 1 ]
282
283
284 def normalized_tool (value: dict[ str , Any]) -> dict[ str , Any]:
285 if value.get( "authorization" ) is not None or value.get( "connector_id" ) is not None :
286 raise _prompt_read.fail(
287 "agent-tool-auth-unverified" ,
288 "Selected MCP inline authorization or connector configuration requires a supported explicit auth-migration contract; preserve it unchanged." ,
289 )
290 names = value.get( "allowed_tools" ) or []
291 if isinstance (names, dict ):
292 names = names.get( "tool_names" ) or []
293 return {
294 "type" : str (value.get( "type" ) or "" ).lower(),
295 "server_label" : value.get( "server_label" ),
296 "server_url" : value.get( "server_url" ),
297 "project_connection_id" : connection_leaf(
298 value.get( "project_connection_id" )
299 ),
300 "allowed_tools" : sorted (names),
301 "require_approval" : str (value.get( "require_approval" ) or "" ).lower(),
302 "headers" : dict ( sorted ((value.get( "headers" ) or {}).items())),
303 }
304
305
306 def normalized_agent_definition (
307 value: dict[ str , Any], server_label: str
308 ) -> dict[ str , Any]:
309 normalized = json.loads(json.dumps(value))
310 for tool in normalized.get( "tools" ) or []:
311 if (
312 not isinstance (tool, dict )
313 or tool.get( "type" ) != "mcp"
314 or tool.get( "server_label" ) != server_label
315 ):
316 continue
317 allowed = tool.get( "allowed_tools" )
318 if isinstance (allowed, list ):
319 tool[ "allowed_tools" ] = { "tool_names" : allowed}
320 elif isinstance (allowed, dict ) and allowed.get( "read_only" ) is None :
321 allowed.pop( "read_only" , None )
322 return normalized
323
324
325 def desired_agent_definition (
326 current: dict[ str , Any],
327 expected_tool: dict[ str , Any],
328 expected_structured_input: tuple[ str , dict[ str , Any]] | None = None ,
329 * ,
330 replace_binding: bool = False ,
331 ) -> tuple[dict[ str , Any], bool ]:
332 desired = json.loads(json.dumps(current))
333 tools = list (desired.get( "tools" ) or [])
334 same_label = [
335 tool
336 for tool in tools
337 if tool.get( "server_label" ) == expected_tool.get( "server_label" )
338 ]
339 if len (same_label) > 1 :
340 raise HelperFailure(
341 "duplicate-tool-label" ,
342 "More than one knowledge-base MCP tool uses the approved label." ,
343 blocked_at = "reconciliation" ,
344 )
345 if same_label and same_label[ 0 ].get( "headers" ) and same_label[ 0 ][ "headers" ] != expected_tool.get( "headers" ):
346 raise _prompt_read.fail(
347 "agent-tool-auth-unverified" ,
348 "Selected MCP headers conflict with the approved authentication recipe; no implicit credential migration is allowed." ,
349 )
350 replace = bool (same_label and normalized_tool(same_label[ 0 ]) != normalized_tool(expected_tool))
351 if replace and ( not replace_binding or same_label[ 0 ].get( "type" ) != "mcp" ):
352 raise HelperFailure(
353 "agent-definition-drift" ,
354 "The existing same-label MCP tool conflicts with the approved binding." ,
355 blocked_at = "reconciliation" ,
356 )
357 instructions = str (desired.get( "instructions" ) or "" ).rstrip()
358 structured_exact = True
359 if expected_structured_input is not None :
360 input_name, input_definition = expected_structured_input
361 structured_inputs = desired.get( "structured_inputs" ) or {}
362 if not isinstance (structured_inputs, dict ):
363 raise HelperFailure(
364 "agent-definition-drift" ,
365 "Existing structured inputs are not an object." ,
366 blocked_at = "reconciliation" ,
367 )
368 current_input = structured_inputs.get(input_name)
369 if current_input is not None and current_input != input_definition:
370 raise HelperFailure(
371 "agent-definition-drift" ,
372 "The permission-forwarding structured input conflicts with the approved binding." ,
373 blocked_at = "reconciliation" ,
374 )
375 structured_exact = current_input == input_definition
376 tool_exact = bool (same_label) and not replace
377 grounding_exact = GROUNDING in instructions
378 if tool_exact and grounding_exact and structured_exact:
379 return desired, False
380 if replace:
381 replacement = { ** same_label[ 0 ], ** expected_tool}
382 if "headers" not in expected_tool:
383 replacement.pop( "headers" , None )
384 desired[ "tools" ] = [replacement if tool is same_label[ 0 ] else tool for tool in tools]
385 elif not tool_exact:
386 tools.append(expected_tool)
387 desired[ "tools" ] = tools
388 if not grounding_exact:
389 desired[ "instructions" ] = f " { instructions }\n\n{ GROUNDING } " .strip()
390 if expected_structured_input is not None and not structured_exact:
391 input_name, input_definition = expected_structured_input
392 structured_inputs = dict (desired.get( "structured_inputs" ) or {})
393 structured_inputs[input_name] = input_definition
394 desired[ "structured_inputs" ] = structured_inputs
395 return desired, True
396
397
398 def _validate_plan (plan: dict[ str , Any], * , resolved: bool = True ) -> None :
399 reject_secrets(plan)
400 require_allowed_fields(plan, PLAN_FIELDS , label = "Prompt connection plan" )
401 if plan.get( "grounding_instructions" ) != GROUNDING :
402 raise HelperFailure(
403 "grounding-instructions-mismatch" ,
404 "The plan must bind the current exact grounding_instructions; obtain new approval." ,
405 blocked_at = "confirmation" ,
406 )
407 network = plan.get( "network" )
408 if network is not None :
409 if not isinstance (network, dict ):
410 raise HelperFailure(
411 "input-schema-invalid" ,
412 "network must be an object." ,
413 blocked_at = "input-resolution" ,
414 )
415 require_allowed_fields(
416 network,
417 { "posture" , "evidence" },
418 label = "network" ,
419 )
420 if plan.get( "operation" ) != "connect" :
421 raise HelperFailure(
422 "operation-invalid" ,
423 "Prompt helper supports only operation connect." ,
424 blocked_at = "input-resolution" ,
425 )
426 if plan.get( "cleanup_approved" ) is not False :
427 raise HelperFailure(
428 "cleanup-boundary-invalid" ,
429 "Connection approval must not include cleanup." ,
430 blocked_at = "confirmation" ,
431 )
432 if plan.get( "sdk_major" ) != 2 :
433 raise HelperFailure(
434 "sdk-version-invalid" ,
435 "The approved plan must bind azure-ai-projects major version 2." ,
436 blocked_at = "input-resolution" ,
437 )
438 _project_identity(plan)
439 connection = plan.get( "connection" )
440 agent = plan.get( "agent" )
441 rbac = plan.get( "rbac_verified" )
442 if not isinstance (connection, dict ) or not isinstance (agent, dict ):
443 raise HelperFailure(
444 "input-schema-invalid" ,
445 "connection and agent objects are required." ,
446 blocked_at = "input-resolution" ,
447 )
448 require_allowed_fields(
449 connection,
450 { "name" , "target" , "action" , "expected_etag" , "is_shared_to_all" },
451 label = "Prompt connection target" ,
452 )
453 if not isinstance (connection.get( "is_shared_to_all" , True ), bool ):
454 raise HelperFailure(
455 "connection-invalid" ,
456 "connection.is_shared_to_all must be a boolean." ,
457 blocked_at = "input-resolution" ,
458 )
459 require_allowed_fields(
460 agent,
461 { "name" , "version" , "model" , "expected_definition_digest" , "binding_action" },
462 label = "Prompt agent target" ,
463 )
464 target = connection.get( "target" )
465 parsed_target = urlsplit(target) if isinstance (target, str ) else None
466 if (
467 parsed_target is None
468 or parsed_target.scheme != "https"
469 or parsed_target.hostname is None
470 or SEARCH_HOST .fullmatch(parsed_target.hostname) is None
471 or KB_MCP_PATH .fullmatch(parsed_target.path) is None
472 or parse_qs(parsed_target.query) != { "api-version" : [ "2026-08-01-preview" ]}
473 or parsed_target.fragment
474 or parsed_target.username
475 or parsed_target.password
476 or parsed_target.port not in { None , 443 }
477 ):
478 raise HelperFailure(
479 "connection-invalid" ,
480 "Connection target must be the exact preview knowledge-base MCP endpoint." ,
481 blocked_at = "input-resolution" ,
482 )
483 if connection.get( "action" ) not in { "create" , "update" , "reuse" }:
484 raise HelperFailure(
485 "connection-invalid" ,
486 "connection.action must be create, update, or reuse." ,
487 blocked_at = "input-resolution" ,
488 )
489 if plan.get( "allowed_tools" ) != [ "knowledge_base_retrieve" ]:
490 raise HelperFailure(
491 "tool-policy-invalid" ,
492 "allowed_tools must contain only knowledge_base_retrieve." ,
493 blocked_at = "input-resolution" ,
494 )
495 if plan.get( "require_approval" ) != "never" :
496 raise HelperFailure(
497 "tool-policy-invalid" ,
498 "The approved base-only MCP tool policy requires require_approval never." ,
499 blocked_at = "input-resolution" ,
500 )
501 forwarding = plan.get( "permission_forwarding" )
502 if not isinstance (forwarding, dict ) or forwarding.get( "mode" ) not in {
503 "not-applicable" ,
504 "structured-input" ,
505 }:
506 raise HelperFailure(
507 "permission-forwarding-invalid" ,
508 "permission_forwarding.mode must be not-applicable or structured-input." ,
509 blocked_at = "input-resolution" ,
510 )
511 require_allowed_fields(
512 forwarding,
513 { "mode" , "name" },
514 label = "Permission forwarding" ,
515 )
516 if forwarding[ "mode" ] == "structured-input" and forwarding.get( "name" ) != (
517 "search_auth_token"
518 ):
519 raise HelperFailure(
520 "permission-forwarding-invalid" ,
521 "Permission forwarding requires the search_auth_token structured input." ,
522 blocked_at = "input-resolution" ,
523 )
524 required_agent = { "name" , "version" }
525 if resolved:
526 required_agent |= { "model" , "expected_definition_digest" }
527 if not required_agent.issubset(agent) or not all (
528 isinstance (agent[field], str ) and agent[field]
529 for field in required_agent
530 ):
531 raise HelperFailure(
532 "agent-invalid" ,
533 "Agent name, version, model, and expected definition digest are required." ,
534 blocked_at = "input-resolution" ,
535 )
536 if not isinstance (rbac, dict ):
537 raise HelperFailure( "rbac-unverified" , "An exact role-assignment binding is required." , blocked_at = "input-resolution" )
538 if resolved and (
539 not isinstance (rbac, dict )
540 or rbac.get( "verified" ) is not True
541 or not rbac.get( "assignment_id" )
542 or not rbac.get( "principal_id" )
543 or not rbac.get( "scope" )
544 or rbac.get( "role" ) != "Search Index Data Reader"
545 ):
546 raise HelperFailure(
547 "rbac-unverified" ,
548 "Exact Search Index Data Reader assignment readback is required." ,
549 blocked_at = "reconciliation" ,
550 )
551 require_allowed_fields(
552 rbac,
553 { "verified" , "assignment_id" , "principal_id" , "role" , "scope" },
554 label = "RBAC verification" ,
555 )
556 if resolved and SHA256 .fullmatch(agent[ "expected_definition_digest" ]) is None :
557 raise HelperFailure(
558 "agent-invalid" ,
559 "expected_definition_digest must be a canonical SHA-256 digest." ,
560 blocked_at = "input-resolution" ,
561 )
562 for label, value in (( "connection" , connection.get( "name" )), ( "agent" , agent.get( "name" )), ( "version" , agent.get( "version" ))):
563 if not isinstance (value, str ) or not _prompt_read. NAME .fullmatch(value):
564 raise _prompt_read.fail( "input-schema-invalid" , label + " requires an exact bounded name." )
565 if agent[ "version" ].casefold() in { "latest" , "default" }:
566 raise _prompt_read.fail( "agent-version-unresolved" , "Select an exact agent version after portal activity, never implicit latest." )
567 if agent.get( "binding_action" , "ensure" ) not in { "ensure" , "replace-selected" }:
568 raise _prompt_read.fail( "agent-binding-invalid" , "Choose ensure or an explicit replace-selected binding delta." )
569 _prompt_read.binding(plan)
570 if any (key in plan for key in ( "connection_plan_version" , "verified_dependencies" , "agent_versions" )):
571 if (plan.get( "connection_plan_version" ) != "1.0" or not isinstance (plan.get( "verified_dependencies" ), dict )
572 or not isinstance (plan.get( "agent_versions" ), dict ) or not plan[ "agent_versions" ]
573 or len (plan[ "agent_versions" ]) > 200 ):
574 raise _prompt_read.fail( "connection-plan-invalid" , "Retain complete supported planner dependency and version evidence." )
575 if any ( not isinstance (key, str ) or not _prompt_read. NAME .fullmatch(key)
576 or not isinstance (value, str ) or not SHA256 .fullmatch(value)
577 for key, value in plan[ "agent_versions" ].items()):
578 raise _prompt_read.fail( "connection-plan-invalid" , "Agent version evidence must retain exact version/digest pairs." )
579 shapes = {
580 "project" : { "id" , "endpoint" , "principal_id" , "tenant_id" },
581 "search" : { "id" , "endpoint" , "access_digest" }, "knowledge_base" : { "name" , "definition_digest" },
582 "rbac" : { "assignment_id" , "principal_id" , "scope" , "role_definition_id" },
583 "cli_context" : { "subscription_id" , "tenant_id" , "principal" , "principal_type" },
584 }
585 if set (plan[ "verified_dependencies" ]) != set (shapes):
586 raise _prompt_read.fail( "connection-plan-invalid" , "Retain complete planner dependency sections." )
587 for key, fields in shapes.items():
588 section = plan[ "verified_dependencies" ][key]
589 if ( not isinstance (section, dict ) or set (section) != fields
590 or any ( not isinstance (value, str ) or not 1 <= len (value) <= 2048 for value in section.values())):
591 raise _prompt_read.fail( "connection-plan-invalid" , "Dependency evidence has missing or undeclared fields." )
592
593
594 def _unique_resources (resources: list[dict[ str , Any]]) -> list[dict[ str , Any]]:
595 result = []
596 for resource in resources:
597 if resource not in result:
598 result.append(resource)
599 return result
600
601
602 def _unverified_mutation (failure: HelperFailure, identity: dict[ str , Any]) -> HelperFailure:
603 failure.partial = True
604 failure.resources_remaining = _unique_resources(failure.resources_remaining)
605 failure.resources_reused = _unique_resources(failure.resources_reused)
606 failure.resources_unverified = _unique_resources([ * failure.resources_unverified, identity])
607 return failure
608
609
610 def _reconcile_connection (
611 plan: dict[ str , Any],
612 token: str ,
613 * ,
614 transport: Transport,
615 cleanup_capture = None ,
616 ) -> tuple[ str , dict[ str , Any], list[ str ]]:
617 url = _connection_url(plan)
618 desired = connection_definition(plan)
619 request_ids: list[ str ] = []
620 current, ids = _prompt_read.get_object(url, token, transport, absent = True , label = "connection" )
621 request_ids.extend(ids)
622 if current is not None and not isinstance (current, dict ):
623 raise HelperFailure(
624 "connection-readback-invalid" ,
625 "Project connection readback was not a JSON object." ,
626 blocked_at = "reconciliation" ,
627 )
628 if current is not None and _connection_readback(plan, current)[ 0 ]:
629 return "reused" , current, request_ids
630
631 action = plan[ "connection" ][ "action" ]
632 headers = { "Content-Type" : "application/json" }
633 if current is None :
634 if action != "create" :
635 raise HelperFailure(
636 "connection-absent" ,
637 "The approved non-create connection target is absent." ,
638 blocked_at = "reconciliation" ,
639 )
640 headers[ "If-None-Match" ] = "*"
641 completed_action = "created"
642 else :
643 raise HelperFailure(
644 "connection-conflict" ,
645 "The same chosen connection name has a conflicting configuration; preserve it and plan a new explicit name." ,
646 blocked_at = "reconciliation" ,
647 )
648
649 try :
650 result = transport(
651 "PUT" ,
652 url,
653 token,
654 body = canonical_bytes(desired),
655 headers = headers,
656 )
657 except HelperFailure as failure:
658 if not is_ambiguous_mutation_failure(failure):
659 raise
660 if cleanup_capture is not None :
661 _unverified_mutation(failure, {
662 "type" : "project-connection" ,
663 "name" : plan[ "connection" ][ "name" ],
664 "resource_id" : plan[ "project_resource_id" ] + "/connections/" + plan[ "connection" ][ "name" ],
665 })
666 failure.warnings.append( "Connection create acknowledgement is unproven; GET recovery cannot produce ownership or cleanup evidence." )
667 raise
668 return _recover_ambiguous_connection(
669 plan,
670 url,
671 token,
672 desired,
673 completed_action,
674 request_ids,
675 failure,
676 transport = transport,
677 )
678 if result.status not in { 200 , 201 }:
679 if result.status in { 408 , 429 } or result.status >= 500 :
680 if cleanup_capture is not None :
681 raise _unverified_mutation(
682 HelperFailure( "connection-outcome-ambiguous" , "Connection acknowledgement is unproven; no recovery ownership." ,
683 blocked_at = "execution" , status = result.status, request_id = result.request_id),
684 { "type" : "project-connection" , "name" : plan[ "connection" ][ "name" ],
685 "resource_id" : plan[ "project_resource_id" ] + "/connections/" + plan[ "connection" ][ "name" ]},
686 )
687 return _recover_ambiguous_connection(
688 plan,
689 url,
690 token,
691 desired,
692 completed_action,
693 request_ids,
694 HelperFailure(
695 "connection-outcome-ambiguous" ,
696 f "Connection mutation returned ambiguous HTTP { result.status } ." ,
697 blocked_at = "execution" ,
698 request_id = result.request_id,
699 status = result.status,
700 partial = True ,
701 ),
702 transport = transport,
703 )
704 raise HelperFailure(
705 "connection-mutation-failed" ,
706 f "Connection create or update returned HTTP { result.status } ." ,
707 blocked_at = "execution" ,
708 request_id = result.request_id,
709 status = result.status,
710 )
711 if result.request_id:
712 request_ids.append(result.request_id)
713 write = { "action" : completed_action, "connection" : desired[ "name" ]}
714 try :
715 if completed_action == "created" and cleanup_capture is not None :
716 cleanup_receipts.connection_ack(cleanup_capture, plan, result)
717 readback = transport( "GET" , url, token)
718 if (completed_action == "created" and cleanup_capture is not None and readback.status == 200
719 and isinstance (readback.body, dict ) and _connection_readback(plan, readback.body)[ 0 ]):
720 cleanup_receipts.connection_finish(cleanup_capture, plan, readback.body)
721 except HelperFailure as failure:
722 raise HelperFailure(
723 failure.code,
724 failure.message,
725 blocked_at = failure.blocked_at,
726 writes = [write, * failure.writes],
727 resources_remaining = [
728 {
729 "type" : "project-connection" ,
730 "name" : plan[ "connection" ][ "name" ],
731 }
732 ],
733 request_id = failure.request_id,
734 status = failure.http_status,
735 partial = True ,
736 ) from failure
737 if readback.request_id:
738 request_ids.append(readback.request_id)
739 if readback.status != 200 or not isinstance (readback.body, dict ):
740 raise HelperFailure(
741 "connection-readback-invalid" ,
742 "Connection readback did not return one JSON object." ,
743 blocked_at = "verification" ,
744 writes = [write],
745 resources_remaining = [
746 {
747 "type" : "project-connection" ,
748 "name" : plan[ "connection" ][ "name" ],
749 }
750 ],
751 request_id = readback.request_id,
752 partial = True ,
753 )
754 if not _connection_readback(plan, readback.body)[ 0 ]:
755 raise HelperFailure(
756 "connection-readback-mismatch" ,
757 "Connection readback does not match the approved definition." ,
758 blocked_at = "verification" ,
759 writes = [write],
760 resources_remaining = [
761 {
762 "type" : "project-connection" ,
763 "name" : plan[ "connection" ][ "name" ],
764 }
765 ],
766 request_id = readback.request_id,
767 partial = True ,
768 )
769 return completed_action, readback.body, request_ids
770
771
772 def _recover_ambiguous_connection (
773 plan: dict[ str , Any],
774 url: str ,
775 token: str ,
776 desired: dict[ str , Any],
777 completed_action: str ,
778 request_ids: list[ str ],
779 failure: HelperFailure,
780 * ,
781 transport: Transport,
782 ) -> tuple[ str , dict[ str , Any], list[ str ]]:
783 identity = {
784 "type" : "project-connection" ,
785 "name" : plan[ "connection" ][ "name" ],
786 }
787 if failure.request_id:
788 request_ids.append(failure.request_id)
789 try :
790 readback = transport( "GET" , url, token)
791 except HelperFailure as readback_failure:
792 raise HelperFailure(
793 "connection-outcome-ambiguous" ,
794 "Connection mutation and same-identity readback are ambiguous." ,
795 blocked_at = "verification" ,
796 resources_remaining = [identity],
797 request_id = readback_failure.request_id or failure.request_id,
798 status = failure.http_status,
799 partial = True ,
800 ) from readback_failure
801 if readback.request_id:
802 request_ids.append(readback.request_id)
803 if (
804 readback.status != 200
805 or not isinstance (readback.body, dict )
806 or not _connection_readback(plan, readback.body)[ 0 ]
807 ):
808 raise HelperFailure(
809 "connection-outcome-ambiguous" ,
810 "Same-identity readback did not prove the approved connection mutation." ,
811 blocked_at = "verification" ,
812 resources_remaining = [identity],
813 request_id = readback.request_id or failure.request_id,
814 status = failure.http_status,
815 partial = True ,
816 )
817 return completed_action, readback.body, request_ids
818
819
820 def _load_sdk () -> tuple[Any, Any, Any, Any, Any]:
821 try :
822 if version( "azure-ai-projects" ).split( "." , 1 )[ 0 ] != SDK_MAJOR :
823 raise HelperFailure(
824 "sdk-version-invalid" ,
825 "azure-ai-projects 2.x is required." ,
826 blocked_at = "execution" ,
827 )
828 from azure.ai.projects import AIProjectClient
829 from azure.ai.projects.models import (
830 MCPTool,
831 PromptAgentDefinition,
832 StructuredInputDefinition,
833 )
834 from azure.core.exceptions import AzureError
835 from azure.identity import AzureCliCredential
836 except PackageNotFoundError as exc:
837 raise HelperFailure(
838 "sdk-unavailable" ,
839 "azure-ai-projects 2.x is not installed." ,
840 blocked_at = "execution" ,
841 ) from exc
842 except ImportError as exc:
843 raise HelperFailure(
844 "sdk-unavailable" ,
845 "azure-ai-projects, azure-identity, and azure-core are required." ,
846 blocked_at = "execution" ,
847 ) from exc
848 return (
849 AIProjectClient,
850 MCPTool,
851 PromptAgentDefinition,
852 StructuredInputDefinition,
853 (AzureCliCredential, AzureError),
854 )
855
856
857 def _version_ids (agents, name: str ) -> list[ str ]:
858 result = agents.list_versions( agent_name = name, include_drafts = True )
859 pages = result.by_page() if hasattr (result, "by_page" ) else [result]
860 versions = []
861 for page_number, page in enumerate (pages, 1 ):
862 if page_number > 200 :
863 raise _prompt_read.fail( "agent-version-limit" , "Selected-agent version pages exceed 200; do not truncate." )
864 for item in page:
865 value = getattr (item, "version" , None )
866 if isinstance (value, bool ) or not isinstance (value, ( str , int )) or not _prompt_read. NAME .fullmatch( str (value)):
867 raise _prompt_read.fail( "agent-version-invalid" , "Selected-agent version inventory is malformed." )
868 if str (value) in versions:
869 raise _prompt_read.fail( "agent-version-ambiguous" , "Duplicate version identities in scoped inventory." )
870 versions.append( str (value))
871 if len (versions) > 200 :
872 raise _prompt_read.fail( "agent-version-limit" , "Selected agent has more than 200 versions; do not truncate." )
873 return sorted (versions)
874
875
876 def _load_connection_sdk () -> tuple[Any, Any, Any, Any, Any]:
877 sdk = _load_sdk()
878 installed = re.match( r " ^ 2 \. (\d + ) \. " , version( "azure-ai-projects" ))
879 if installed is None or int (installed[ 1 ]) < 4 :
880 raise _prompt_read.fail( "sdk-version-invalid" , "Complete Prompt version reads require azure-ai-projects>=2.4.0,<3, including drafts." )
881 return sdk
882
883
884 def _version_state (agents, name: str , version_id: str ) -> tuple[dict[ str , Any], dict[ str , Any]]:
885 item = agents.get_version( agent_name = name, agent_version = version_id)
886 return _checked_version_state(item, name, version_id)
887
888
889 def _checked_version_state (item, name: str , version_id: str ) -> tuple[dict[ str , Any], dict[ str , Any]]:
890 definition = getattr (item, "definition" , None )
891 if (
892 getattr (item, "name" , None ) != name or str ( getattr (item, "version" , "" )) != version_id
893 or definition is None or not hasattr (definition, "as_dict" )
894 ):
895 raise _prompt_read.fail( "agent-version-invalid" , "SDK readback does not bind the exact selected agent/version." )
896 value = definition.as_dict()
897 if not isinstance (value, dict ) or len (canonical_bytes(value)) > MAX_BYTES :
898 raise _prompt_read.fail( "agent-definition-invalid" , "Agent definition must be a bounded object." )
899 tools = value.get( "tools" )
900 if tools is not None and ( not isinstance (tools, list ) or any ( not isinstance (tool, dict ) for tool in tools)):
901 raise _prompt_read.fail( "agent-definition-invalid" , "Agent tool inventory is malformed." )
902 options = {}
903 for key in ( "metadata" , "description" , "draft" , "blueprint_reference" ):
904 option = getattr (item, key, None )
905 if option is not None :
906 options[key] = option.as_dict() if hasattr (option, "as_dict" ) else copy.deepcopy(option)
907 metadata = options.get( "metadata" , {})
908 if (
909 not isinstance (metadata, dict ) or len (metadata) > 16
910 or any ( not isinstance (key, str ) or not 1 <= len (key) <= 64
911 or not isinstance (item, str ) or len (item) > 512 for key, item in metadata.items())
912 or ( "description" in options and not isinstance (options[ "description" ], str ))
913 or ( "draft" in options and not isinstance (options[ "draft" ], bool ))
914 or ( "blueprint_reference" in options and not isinstance (options[ "blueprint_reference" ], dict ))
915 or len (canonical_bytes({ "definition" : value, "version_options" : options})) > MAX_BYTES
916 ):
917 raise _prompt_read.fail( "agent-version-invalid" , "Writable version settings are malformed or exceed the readback bound." )
918 return value, options
919
920
921 def _connect_agent (
922 plan: dict[ str , Any],
923 * ,
924 sdk_loader: Callable[[], tuple[Any, Any, Any, Any, Any]] = _load_connection_sdk,
925 read_only: bool = False ,
926 resolve: bool = False ,
927 before_write: Callable[[], None ] | None = None ,
928 cleanup_capture = None ,
929 ) -> tuple[ str , dict[ str , Any]]:
930 (
931 AIProjectClient,
932 MCPTool,
933 PromptAgentDefinition,
934 StructuredInputDefinition,
935 extras,
936 ) = sdk_loader()
937 AzureCliCredential, AzureError = extras
938 agent = plan[ "agent" ]
939 connection = plan[ "connection" ]
940 created_write: dict[ str , Any] | None = None
941 client = AIProjectClient(
942 endpoint = _project_endpoint(plan[ "project_endpoint" ]),
943 credential = AzureCliCredential(),
944 )
945 try :
946 versions = _version_ids(client.agents, agent[ "name" ])
947 if agent[ "version" ] not in versions:
948 raise HelperFailure(
949 "agent-version-absent" ,
950 "The exact existing Prompt Agent version was not found." ,
951 blocked_at = "reconciliation" ,
952 )
953 current_definition, version_options = _version_state(client.agents, agent[ "name" ], agent[ "version" ])
954 if (
955 current_definition.get( "kind" ) != "prompt"
956 or not isinstance (current_definition.get( "model" ), str ) or not current_definition[ "model" ]
957 or ( not resolve and current_definition.get( "model" ) != agent[ "model" ])
958 ):
959 raise HelperFailure(
960 "agent-definition-drift" ,
961 "Agent type or model differs from the approved plan." ,
962 blocked_at = "reconciliation" ,
963 )
964 if not resolve and digest(current_definition) != agent[ "expected_definition_digest" ]:
965 raise HelperFailure(
966 "agent-definition-drift" ,
967 "Agent definition digest changed after approval." ,
968 blocked_at = "reconciliation" ,
969 )
970 for tool in current_definition.get( "tools" ) or []:
971 if tool.get( "server_label" ) != "knowledge-base" :
972 continue
973 reference = tool.get( "project_connection_id" )
974 leaf = connection_leaf(reference)
975 resource_id = plan[ "project_resource_id" ] + "/connections/" + leaf
976 if ( not isinstance (reference, str ) or not _prompt_read. NAME .fullmatch(leaf)
977 or (reference != leaf and reference.casefold() not in {
978 resource_id.casefold(), ( MANAGEMENT_AUDIENCE + resource_id).casefold(),
979 })):
980 raise _prompt_read.fail( "agent-binding-invalid" , "The selected tool connection must bind this exact project, not another project's same-name connection." )
981 tool_arguments = {
982 "server_label" : "knowledge-base" ,
983 "server_url" : connection[ "target" ],
984 "project_connection_id" : connection[ "name" ],
985 "allowed_tools" : plan[ "allowed_tools" ],
986 "require_approval" : plan[ "require_approval" ],
987 }
988 expected_structured_input = None
989 if plan[ "permission_forwarding" ][ "mode" ] == "structured-input" :
990 input_name = plan[ "permission_forwarding" ][ "name" ]
991 tool_arguments[ "headers" ] = {
992 "x-ms-query-source-authorization" : f " {{{{{ input_name }}}}} "
993 }
994 input_definition = StructuredInputDefinition(
995 description = "Per-user Azure AI Search bearer token" ,
996 required = True ,
997 schema = { "type" : "string" },
998 ).as_dict()
999 expected_structured_input = (input_name, input_definition)
1000 expected_tool = MCPTool(
1001 ** tool_arguments,
1002 ).as_dict()
1003 desired, changed = desired_agent_definition(
1004 current_definition,
1005 expected_tool,
1006 expected_structured_input,
1007 replace_binding = agent.get( "binding_action" ) == "replace-selected" ,
1008 )
1009 normalized_desired = normalized_agent_definition(
1010 desired, tool_arguments[ "server_label" ]
1011 )
1012 manifest = {agent[ "version" ]: digest({ "definition" : current_definition, "version_options" : version_options})}
1013 exact_versions = []
1014 for version_id in versions:
1015 if version_id == agent[ "version" ]:
1016 continue
1017 candidate_definition, candidate_options = _version_state(client.agents, agent[ "name" ], version_id)
1018 manifest[version_id] = digest({ "definition" : candidate_definition, "version_options" : candidate_options})
1019 if candidate_options == version_options and normalized_agent_definition(
1020 candidate_definition, tool_arguments[ "server_label" ]
1021 ) == normalized_desired:
1022 exact_versions.append(
1023 {
1024 "name" : agent[ "name" ],
1025 "version" : version_id,
1026 "definition_digest" : digest(candidate_definition),
1027 }
1028 )
1029 if _version_ids(client.agents, agent[ "name" ]) != versions:
1030 raise _prompt_read.fail( "agent-version-drift" , "Version inventory changed during readback; refresh after portal activity." )
1031 if plan.get( "agent_versions" ) is not None and plan[ "agent_versions" ] != manifest:
1032 raise _prompt_read.fail( "agent-version-drift" , "Agent versions changed since planning; refresh the exact scoped inventory." )
1033 if changed and len (exact_versions) > 1 :
1034 raise HelperFailure(
1035 "duplicate-desired-agent-version" ,
1036 "More than one existing Prompt Agent version matches the approved result." ,
1037 blocked_at = "reconciliation" ,
1038 )
1039 reuse = (
1040 { "name" : agent[ "name" ], "version" : agent[ "version" ], "definition_digest" : digest(current_definition)}
1041 if not changed else exact_versions[ 0 ] if exact_versions else None
1042 )
1043 typed_definition = None
1044 if reuse is None :
1045 if len (versions) >= 200 :
1046 raise _prompt_read.fail(
1047 "agent-version-limit" ,
1048 "Creating another version would exceed the 200-version inventory bound; exact reuse remains available." ,
1049 )
1050 try :
1051 typed_definition = PromptAgentDefinition(desired)
1052 serialized = typed_definition.as_dict()
1053 except ( TypeError , ValueError ) as error:
1054 raise _prompt_read.fail( "agent-definition-unsupported" , "Installed SDK cannot preserve the complete agent definition; no configuration write is allowed." ) from error
1055 if not isinstance (serialized, dict ) or normalized_agent_definition(
1056 serialized, tool_arguments[ "server_label" ]
1057 ) != normalized_desired:
1058 raise _prompt_read.fail( "agent-definition-unsupported" , "SDK serialization would change unrelated agent state; no configuration write is allowed." )
1059 if read_only:
1060 selected_tools = [tool for tool in current_definition.get( "tools" ) or []
1061 if tool.get( "server_label" ) == "knowledge-base" ]
1062 old_connection = connection_leaf(selected_tools[ 0 ].get( "project_connection_id" )) if selected_tools else ""
1063 if old_connection and not _prompt_read. NAME .fullmatch(old_connection):
1064 raise _prompt_read.fail( "agent-binding-invalid" , "Observed selected connection identity is malformed; do not guess its replacement." )
1065 return ( "reused" if reuse else "create" ), {
1066 "agent" : { ** agent, "model" : current_definition[ "model" ],
1067 "expected_definition_digest" : digest(current_definition)},
1068 "versions" : manifest, "desired_digest" : digest(desired), "reused" : reuse,
1069 "binding_action" : agent.get( "binding_action" , "ensure" ),
1070 "grounding_appended" : GROUNDING not in str (current_definition.get( "instructions" ) or "" ),
1071 "previous_connection" : old_connection or None ,
1072 }
1073 if before_write is not None :
1074 before_write()
1075 if reuse is not None :
1076 return "reused" , reuse
1077 native_response = {}
1078 try :
1079 updated = client.agents.create_version(
1080 agent_name = agent[ "name" ],
1081 definition = typed_definition,
1082 ** version_options,
1083 ** ({ "raw_response_hook" : cleanup_receipts.sdk_response_hook(native_response)} if cleanup_capture else {}),
1084 )
1085 except AzureError as exc:
1086 if not is_ambiguous_sdk_error(exc):
1087 raise HelperFailure(
1088 message = "The Prompt Agent SDK create operation failed." ,
1089 blocked_at = "execution" ,
1090 ** sdk_error_metadata(exc, "agent-sdk-failed" ),
1091 ) from exc
1092 if cleanup_capture is not None :
1093 raise HelperFailure( message = "Version create acknowledgement is ambiguous; GET recovery cannot establish cleanup ownership." ,
1094 blocked_at = "execution" , partial = True ,
1095 resources_unverified = [{
1096 "type" : "prompt-agent-version" , "name" : agent[ "name" ],
1097 "project_resource_id" : plan[ "project_resource_id" ],
1098 "definition_digest" : digest(desired),
1099 }],
1100 ** sdk_error_metadata(exc, "agent-create-outcome-ambiguous" )) from exc
1101 identity = {
1102 "type" : "prompt-agent-version" ,
1103 "name" : agent[ "name" ],
1104 "definition_digest" : digest(desired),
1105 "run_owned" : False ,
1106 }
1107 try :
1108 matches = []
1109 for version_id in _version_ids(client.agents, agent[ "name" ]):
1110 candidate_definition, candidate_options = _version_state(client.agents, agent[ "name" ], version_id)
1111 if candidate_options == version_options and normalized_agent_definition(
1112 candidate_definition, tool_arguments[ "server_label" ]
1113 ) == normalized_desired:
1114 matches.append(
1115 {
1116 "name" : agent[ "name" ],
1117 "version" : version_id,
1118 "definition_digest" : digest(candidate_definition),
1119 }
1120 )
1121 except (AzureError, HelperFailure) as readback_exc:
1122 raise HelperFailure(
1123 "agent-create-outcome-ambiguous" ,
1124 "Agent version creation and same-identity readback are ambiguous." ,
1125 blocked_at = "verification" ,
1126 resources_remaining = [identity],
1127 partial = True ,
1128 ** sdk_error_metadata(exc),
1129 ) from readback_exc
1130 if len (matches) == 1 :
1131 raise HelperFailure(
1132 "agent-create-outcome-ambiguous" ,
1133 "An exact version was observed after an ambiguous create, but its creation ownership is unverified." ,
1134 blocked_at = "verification" ,
1135 resources_remaining = [{ ** identity, ** matches[ 0 ]}],
1136 partial = True ,
1137 ** sdk_error_metadata(exc),
1138 ) from exc
1139 raise HelperFailure(
1140 "agent-create-outcome-ambiguous" ,
1141 "Same-agent readback did not identify exactly one approved version." ,
1142 blocked_at = "verification" ,
1143 resources_remaining = [identity],
1144 partial = True ,
1145 ** sdk_error_metadata(exc),
1146 ) from exc
1147 created_write = {
1148 "action" : "created" ,
1149 "agent" : agent[ "name" ],
1150 }
1151 created_identity = {
1152 "type" : "prompt-agent-version" ,
1153 "name" : agent[ "name" ],
1154 }
1155 try :
1156 receipt_target = cleanup_receipts.agent_ack(cleanup_capture, plan, updated, native_response) if cleanup_capture else None
1157 created_version = str ( getattr (updated, "version" , "" ))
1158 if getattr (updated, "name" , None ) != agent[ "name" ] or not _prompt_read. NAME .fullmatch(created_version):
1159 raise _prompt_read.fail( "agent-version-invalid" , "Creation returned an unverified version identity; do not follow another agent name." )
1160 created_write[ "version" ] = created_version
1161 created_identity[ "version" ] = created_version
1162 readback = client.agents.get_version( agent_name = agent[ "name" ], agent_version = created_version)
1163 readback_definition, readback_options = _checked_version_state(readback, agent[ "name" ], created_version)
1164 expected_definition, changed_after = desired_agent_definition(
1165 readback_definition,
1166 expected_tool,
1167 expected_structured_input,
1168 )
1169 if (
1170 changed_after
1171 or readback_options != version_options
1172 or expected_definition != readback_definition
1173 or normalized_agent_definition(
1174 readback_definition, tool_arguments[ "server_label" ]
1175 ) != normalized_desired
1176 ):
1177 raise HelperFailure(
1178 "agent-readback-mismatch" ,
1179 "Created version differs from the approved definition or tool and grounding delta." ,
1180 blocked_at = "verification" ,
1181 )
1182 if cleanup_capture is not None :
1183 cleanup_capture.finish(receipt_target, { "definition_digest" : digest(readback_definition), "etag" : None , "generated" : [],
1184 "version_identity" : cleanup_receipts.version_identity(readback)})
1185 except HelperFailure as failure:
1186 raise HelperFailure(
1187 failure.code,
1188 failure.message,
1189 blocked_at = failure.blocked_at,
1190 writes = [created_write, * failure.writes],
1191 resources_remaining = [
1192 created_identity,
1193 * [
1194 item
1195 for item in failure.resources_remaining
1196 if item != created_identity
1197 ],
1198 ],
1199 request_id = failure.request_id,
1200 status = failure.http_status,
1201 partial = True ,
1202 resources_reused = failure.resources_reused,
1203 resources_unverified = failure.resources_unverified,
1204 warnings = failure.warnings,
1205 ) from failure
1206 return "created" , {
1207 "name" : updated.name,
1208 "version" : str (updated.version),
1209 "definition_digest" : digest(readback_definition),
1210 }
1211 except AzureError as exc:
1212 remaining = (
1213 [
1214 {
1215 "type" : "prompt-agent-version" ,
1216 "name" : created_write[ "agent" ],
1217 "version" : created_write[ "version" ],
1218 }
1219 ]
1220 if created_write is not None
1221 else []
1222 )
1223 raise HelperFailure(
1224 message = "The Prompt Agent SDK operation failed." ,
1225 blocked_at = "execution" ,
1226 writes = [created_write] if created_write is not None else [],
1227 resources_remaining = remaining,
1228 partial = created_write is not None ,
1229 ** sdk_error_metadata(exc, "agent-sdk-failed" ),
1230 ) from exc
1231 finally :
1232 client.close()
1233
1234
1235 def execute (
1236 document: dict[ str , Any],
1237 * ,
1238 token_provider: TokenProvider = azure_cli_token,
1239 transport: Transport = http_request,
1240 sdk_loader: Callable[[], tuple[Any, Any, Any, Any, Any]] = _load_connection_sdk,
1241 cli = run_cli,
1242 cleanup_capture = None ,
1243 ) -> dict[ str , Any]:
1244 plan = document[ "plan" ]
1245 fingerprint = document[ "_computed_fingerprint" ]
1246 if plan.get( "operation" ) == "create-initial-prompt-agent" :
1247 try :
1248 from . import _initial_prompt, prompt_cleanup
1249 except ImportError :
1250 import _initial_prompt, prompt_cleanup
1251 if sdk_loader in (_load_sdk, _load_connection_sdk):
1252 sdk_loader = prompt_cleanup.load_cleanup_sdk
1253 return _initial_prompt.execute(document, capture = cleanup_capture, token_provider = token_provider,
1254 transport = transport, sdk_loader = sdk_loader)
1255 _validate_plan(plan)
1256 if cleanup_capture is not None and (
1257 not isinstance (cleanup_capture, cleanup_receipts.Capture) or cleanup_capture.plan_digest != fingerprint
1258 or cleanup_capture.owner != plan.get( "owner" )
1259 ):
1260 raise HelperFailure( "cleanup-receipt-input-invalid" , "Capture must bind this exact approved connection plan." , blocked_at = "confirmation" )
1261 if cleanup_capture is not None and sdk_loader in (_load_sdk, _load_connection_sdk):
1262 try :
1263 from .prompt_cleanup import load_cleanup_sdk
1264 except ImportError :
1265 from prompt_cleanup import load_cleanup_sdk
1266 loaded = load_cleanup_sdk()
1267 sdk_loader = lambda : loaded
1268 connection_action, connection = "" , {}
1269 request_ids, connection_warnings, connection_write = [], [], []
1270 def preflight_connection ():
1271 nonlocal connection_action, connection, request_ids, connection_write
1272 _, _, warnings, ids = _prompt_read.read_dependencies(
1273 plan, token_provider = token_provider, transport = transport, cli = cli,
1274 capture_context = plan.get( "connection_plan_version" ) is not None ,
1275 )
1276 connection_warnings.extend(warnings)
1277 request_ids.extend(ids)
1278 connection_action, connection, ids = _reconcile_connection(
1279 plan, token_provider( MANAGEMENT_AUDIENCE ), transport = transport,
1280 ** ({ "cleanup_capture" : cleanup_capture} if cleanup_capture else {}),
1281 )
1282 request_ids.extend(ids)
1283 connection_warnings.extend(_connection_readback(plan, connection)[ 1 ])
1284 if connection_action != "reused" :
1285 connection_write = [{ "action" : connection_action, "connection" : plan[ "connection" ][ "name" ]}]
1286 try :
1287 agent_action, agent = _connect_agent(
1288 plan, sdk_loader = sdk_loader, before_write = preflight_connection,
1289 ** ({ "cleanup_capture" : cleanup_capture} if cleanup_capture else {}),
1290 )
1291 except HelperFailure as failure:
1292 raise HelperFailure(
1293 failure.code,
1294 failure.message,
1295 blocked_at = failure.blocked_at,
1296 writes = connection_write + failure.writes,
1297 resources_remaining = _unique_resources(
1298 (
1299 [
1300 {
1301 "type" : "project-connection" ,
1302 "name" : plan[ "connection" ][ "name" ],
1303 }
1304 ]
1305 if connection_write
1306 else []
1307 )
1308 + failure.resources_remaining
1309 ),
1310 resources_unverified = _unique_resources(failure.resources_unverified),
1311 request_id = failure.request_id,
1312 status = failure.http_status,
1313 partial = bool (connection_write or failure.writes or failure.partial),
1314 warnings = connection_warnings + failure.warnings,
1315 resources_reused = _unique_resources((
1316 [{ "type" : "project-connection" , "name" : plan[ "connection" ][ "name" ]}]
1317 if connection_action == "reused" else []
1318 ) + failure.resources_reused),
1319 ) from failure
1320
1321 resources = { "created" : [], "reused" : [], "updated" : [], "skipped" : []}
1322 resources[connection_action].append(
1323 { "type" : "project-connection" , "name" : plan[ "connection" ][ "name" ]}
1324 )
1325 resources[agent_action].append({ "type" : "prompt-agent-version" , ** agent})
1326 return {
1327 "status" : "completed" ,
1328 "outcome" : str (plan.get( "outcome" ) or "connect-existing-prompt-agent" ),
1329 "approved_plan" : { "fingerprint" : fingerprint, "confirmed" : True },
1330 "resources" : resources,
1331 "api_contracts" : [
1332 {
1333 "operation" : "project-connection" ,
1334 "version" : ARM_API_VERSION ,
1335 "preview" : True ,
1336 },
1337 {
1338 "operation" : "prompt-agent-version" ,
1339 "version" : "azure-ai-projects-2.x" ,
1340 "preview" : True ,
1341 },
1342 ],
1343 "data_movement" : { "boundary" : "knowledge-base MCP retrieval" , "result" : "bound" },
1344 "auth" : { "mode" : "managed-identity" , "principals" : [plan[ "rbac_verified" ][ "principal_id" ]]},
1345 "rbac" : { "assignments" : [plan[ "rbac_verified" ][ "assignment_id" ]]},
1346 "network" : plan.get( "network" , { "posture" : "preserved" , "evidence" : None }),
1347 "verification" : {
1348 "connection_readback" : {
1349 "name" : connection.get( "name" ),
1350 "definition_digest" : digest(connection),
1351 "request_ids" : request_ids,
1352 "actual_is_shared_to_all" : connection[ "properties" ][ "isSharedToAll" ],
1353 },
1354 "agent_readback" : agent,
1355 "permission_forwarding" : plan[ "permission_forwarding" ],
1356 "idempotency" : "compatible connection and exact tool/grounding state is zero-write" ,
1357 "agent_invocation" : "not-run; required for end-to-end verification" ,
1358 },
1359 "warnings" : connection_warnings,
1360 "ownership" : {
1361 "run_owned" : connection_write
1362 + ([{ "type" : "prompt-agent-version" , ** agent}] if agent_action == "created" else []),
1363 "reused_not_owned" : (
1364 [{ "type" : "project-connection" , "name" : plan[ "connection" ][ "name" ]}]
1365 if connection_action == "reused"
1366 else []
1367 )
1368 + ([{ "type" : "prompt-agent-version" , ** agent}] if agent_action == "reused" else []),
1369 "owner" : plan.get( "owner" ),
1370 },
1371 "cleanup" : {
1372 "status" : "not-requested" ,
1373 "separate_confirmation_required" : True ,
1374 },
1375 }
1376
1377
1378 def plan_source (request: dict[ str , Any], * , token_provider = azure_cli_token,
1379 transport = http_request, sdk_loader = _load_connection_sdk, cli = run_cli) -> dict[ str , Any]:
1380 if not isinstance (request, dict ):
1381 raise _prompt_read.fail( "input-schema-invalid" , "Prompt planning requires a resolved intent object." )
1382 reject_secrets(request)
1383 fields = {
1384 "schema_version" , "project_resource_id" , "project_endpoint" , "search_resource_id" ,
1385 "knowledge_base_name" , "agent_name" , "agent_version" , "connection_name" ,
1386 "is_shared_to_all" , "binding_action" , "role_assignment_id" , "permission_forwarding" ,
1387 "network" , "owner" ,
1388 }
1389 require_allowed_fields(request, fields, label = "Prompt planning intent" )
1390 if set (request) != fields or request[ "schema_version" ] != "1.0" :
1391 raise _prompt_read.fail( "input-schema-invalid" , "Supply all documented Prompt intent decisions; no inferred future approval." )
1392 for field in ( "knowledge_base_name" , "agent_name" , "agent_version" , "connection_name" ):
1393 if not isinstance (request[field], str ) or not _prompt_read. NAME .fullmatch(request[field]):
1394 raise _prompt_read.fail( "input-schema-invalid" , "Select exact bounded KB, agent/version and connection names." )
1395 scope = request[ "search_resource_id" ]
1396 match = _prompt_read. SEARCH_ID .fullmatch(scope) if isinstance (scope, str ) else None
1397 if match is None or not isinstance (request[ "owner" ], str ) or not 1 <= len (request[ "owner" ]) <= 256 :
1398 raise _prompt_read.fail( "input-schema-invalid" , "Select the exact Search resource ID and owner." )
1399 network = request[ "network" ]
1400 if ( not isinstance (network, dict ) or set (network) != { "posture" , "evidence" }
1401 or any ( not isinstance (value, str ) or not 1 <= len (value) <= 4096 for value in network.values())):
1402 raise _prompt_read.fail( "input-schema-invalid" , "Supply resolved network posture and owner-verified reachability evidence." )
1403 endpoint = "https://" + match[ "name" ].lower() + ".search.windows.net"
1404 plan = {
1405 "operation" : "connect" , "outcome" : "connect-existing-prompt-agent" , "sdk_major" : 2 ,
1406 "project_resource_id" : request[ "project_resource_id" ], "project_endpoint" : request[ "project_endpoint" ],
1407 "connection" : {
1408 "name" : request[ "connection_name" ],
1409 "target" : endpoint + "/knowledgebases/" + quote(request[ "knowledge_base_name" ], safe = "" ) + "/mcp?api-version=2026-08-01-preview" ,
1410 "action" : "create" , "is_shared_to_all" : request[ "is_shared_to_all" ],
1411 },
1412 "agent" : { "name" : request[ "agent_name" ], "version" : request[ "agent_version" ], "binding_action" : request[ "binding_action" ]},
1413 "rbac_verified" : { "verified" : False , "assignment_id" : request[ "role_assignment_id" ],
1414 "scope" : scope, "role" : "Search Index Data Reader" },
1415 "allowed_tools" : [ "knowledge_base_retrieve" ], "require_approval" : "never" ,
1416 "permission_forwarding" : copy.deepcopy(request[ "permission_forwarding" ]),
1417 "grounding_instructions" : GROUNDING , "network" : copy.deepcopy(request[ "network" ]),
1418 "owner" : request[ "owner" ], "cleanup_approved" : False ,
1419 }
1420 _validate_plan(plan, resolved = False )
1421 sdk = sdk_loader()
1422 state, profile, warnings, request_ids = _prompt_read.read_dependencies(
1423 plan, token_provider = token_provider, transport = transport, cli = cli, capture_context = True ,
1424 )
1425 plan[ "rbac_verified" ].update( verified = True , principal_id = state[ "project" ][ "principal_id" ])
1426 current, ids = _prompt_read.get_object(
1427 _connection_url(plan), token_provider( MANAGEMENT_AUDIENCE ), transport, absent = True , label = "connection" ,
1428 )
1429 request_ids.extend(ids)
1430 if current is not None :
1431 matches, connection_warnings = _connection_readback(plan, current)
1432 if not matches:
1433 raise _prompt_read.fail( "connection-conflict" , "The exact chosen connection name has another configuration; preserve it and explicitly select a new deterministic name." )
1434 warnings.extend(connection_warnings)
1435 plan[ "connection" ][ "action" ] = "reuse"
1436 agent_action, agent = _connect_agent(plan, sdk_loader =lambda : sdk, resolve = True , read_only = True )
1437 plan.update( connection_plan_version = "1.0" , verified_dependencies = state, agent_versions = agent[ "versions" ])
1438 plan[ "agent" ] = agent[ "agent" ]
1439 _validate_plan(plan)
1440 _, _, refreshed_warnings, ids = _prompt_read.read_dependencies(
1441 plan, token_provider = token_provider, transport = transport, cli = cli, capture_context = True ,
1442 )
1443 warnings = list ( dict .fromkeys(warnings + refreshed_warnings))
1444 request_ids.extend(ids)
1445 refreshed, ids = _prompt_read.get_object(
1446 _connection_url(plan), token_provider( MANAGEMENT_AUDIENCE ), transport, absent = True , label = "connection" ,
1447 )
1448 request_ids.extend(ids)
1449 if ((current is None ) != (refreshed is None )
1450 or (refreshed is not None and not _connection_readback(plan, refreshed)[ 0 ])):
1451 raise _prompt_read.fail( "connection-drift" , "Selected connection changed during planning; refresh before approval." )
1452 mutation = plan[ "connection" ][ "action" ] != "reuse" or agent_action != "reused"
1453 return {
1454 "status" : "planned" , "outcome" : plan[ "outcome" ], "writes_performed" : [],
1455 "execution_input" : { "schema_version" : "1.0" , "plan" : plan,
1456 "approval" : { "confirmed" : False , "fingerprint" : digest(plan)}},
1457 "approval_summary" : {
1458 "project_resource_id" : plan[ "project_resource_id" ], "connection_name" : request[ "connection_name" ],
1459 "connection_action" : plan[ "connection" ][ "action" ], "kb_endpoint" : plan[ "connection" ][ "target" ],
1460 "agent_name" : request[ "agent_name" ], "selected_version" : request[ "agent_version" ],
1461 "agent_action" : agent_action, "binding_action" : request[ "binding_action" ],
1462 "previous_connection" : agent[ "previous_connection" ],
1463 "reused_version" : agent[ "reused" ][ "version" ] if agent[ "reused" ] else None ,
1464 "grounding_appended" : agent[ "grounding_appended" ], "project_principal_id" : state[ "project" ][ "principal_id" ],
1465 "role" : "Search Index Data Reader" , "role_scope" : scope, "kb_profile" : profile,
1466 "sharing" : request[ "is_shared_to_all" ], "network" : request[ "network" ][ "posture" ],
1467 "execution_required" : mutation, "mutation_approval_required" : mutation,
1468 "preservation" : "All unrelated agent fields/tools, prior versions and other connection names remain." ,
1469 "version_settings" : "Preserve description, metadata, draft state and blueprint reference." ,
1470 "cleanup" : "Separate plan and approval; no cleanup planner." ,
1471 "cost" : "Configuration only; no inference. Later agent/KB invocations incur separate usage." ,
1472 },
1473 "request_ids" : request_ids, "warnings" : warnings,
1474 "verification" : {
1475 "configuration" : "prerequisites observed; configuration not applied" if mutation else "exact existing configuration read back" ,
1476 "agent_invocation" : "not-run" , "retrieval" : "unverified" ,
1477 },
1478 }
1479
1480
1481 def main (argv: list[ str ] | None = None ) -> int :
1482 parser = argparse.ArgumentParser( allow_abbrev = False )
1483 modes = parser.add_mutually_exclusive_group( required = True )
1484 modes.add_argument( "--input" , type = Path)
1485 modes.add_argument( "--plan-initial" , type = Path, help = "Plan the existing local v1 initial-agent creation with protected provenance." )
1486 modes.add_argument( "--plan" , type = Path)
1487 cleanup_receipts.add_argument(parser)
1488 args = parser.parse_args(argv)
1489 fingerprint: str | None = None
1490 owner: Any = None
1491 outcome = "connect-existing-prompt-agent"
1492 capture = None
1493 try :
1494 if args.plan_initial or args.plan:
1495 if args.cleanup_receipt_dir:
1496 raise HelperFailure( "input-schema-invalid" , "Creation capture requires approved --input." , blocked_at = "confirmation" )
1497 try :
1498 from . import _initial_prompt, prompt_cleanup
1499 from ._bootstrap_io import read_json
1500 except ImportError :
1501 import _initial_prompt, prompt_cleanup
1502 from _bootstrap_io import read_json
1503 result = (
1504 _initial_prompt.plan(read_json(args.plan_initial), prompt_cleanup.load_cleanup_sdk)
1505 if args.plan_initial else plan_source(read_json(args.plan))
1506 )
1507 emit_result(result, preserve_unapproved_input = True )
1508 return 0
1509 document, plan, fingerprint = load_approved_input(args.input)
1510 document[ "_computed_fingerprint" ] = fingerprint
1511 owner = plan.get( "owner" )
1512 outcome = str (plan.get( "outcome" ) or outcome)
1513 capture = cleanup_receipts.Capture(args.cleanup_receipt_dir, document) if args.cleanup_receipt_dir else None
1514 result = execute(document, ** ({ "cleanup_capture" : capture} if capture else {}))
1515 except HelperFailure as failure:
1516 result = blocked_result(
1517 failure,
1518 outcome = outcome,
1519 fingerprint = fingerprint,
1520 owner = owner,
1521 )
1522 if capture is not None :
1523 result[ "cleanup_receipts" ] = capture.summaries
1524 if result[ "status" ] == "partial" :
1525 remaining = result[ "resources_remaining" ]
1526 remaining[ "unverified" ] = _unique_resources(remaining.get( "unverified" , []) + [
1527 item for item in remaining[ "run_owned" ] if item.get( "run_owned" ) is False
1528 ])
1529 remaining[ "run_owned" ] = [
1530 item for item in remaining[ "run_owned" ] if item.get( "run_owned" ) is not False
1531 ]
1532 emit_result(result)
1533 return 3 if result[ "status" ] == "partial" else 2
1534 if capture is not None :
1535 result[ "cleanup_receipts" ] = capture.summaries
1536 emit_result(result)
1537 return 0
1538
1539
1540 if __name__ == "__main__" :
1541 sys.exit(main())