Setting the file. One moment.
Copilot SDK With Toolbox · Foundry Agentic Loop · github/awesome-copilot · Skills Docs
ContentsBack to the top of the page 180.10
Foundry Toolbox
async def __aexit__
— line 127
This file
Number 180.5
Position 5 of 54
Type Python
Size 14 KB
Lines 321 references/ copilot-sdk-with-toolbox.py
Python · 321 lines · 14 KB
TOOLBOX_ENDPOINT Consumer URL <project>/toolboxes/<name>/mcp?api-version=v1, mapped
15 from TOOLBOX_<NAME>_MCP_ENDPOINT; it serves the default version.
16 MODEL_API_BASE_URL The discovered .../openai/v1/ base and its deployment. Required
17 AZURE_AI_MODEL_DEPLOYMENT_NAME when hosted; locally, unset means the Copilot default model.
18 COPILOT_OTLP_ENDPOINT Optional OTLP HTTP receiver for runtime spans.
19
20 Skills attached to the toolbox are MCP resources (skill://<name>/SKILL.md) that follow each
21 skill's default version, so publishing a skill needs no redeploy. For multi-file skills, or
22 skills outside a toolbox, use the Skills API download in microsoft-foundry. Verify that served
23 bodies keep their frontmatter; load_skills rebuilds it when missing.
24 """
25
26 from __future__ import annotations
27
28 import asyncio
29 import json
30 import logging
31 import os
32 import re
33 import tempfile
34 from collections.abc import Callable
35 from dataclasses import dataclass, field
36 from pathlib import Path
37 from typing import Any
38
39 import httpx
40 from azure.core.credentials_async import AsyncTokenCredential
41 from azure.identity.aio import DefaultAzureCredential
42 from copilot import CopilotClient, SessionEventType, Tool, ToolInvocation, ToolResult
43 from copilot.generated.rpc import PermissionDecisionApproveOnce, PermissionDecisionReject
44 from copilot.generated.session_events import PermissionRequestCustomTool, PermissionRequestRead
45 from copilot.session import ProviderConfig, ProviderTokenArgs
46 from opentelemetry import trace
47 from opentelemetry.trace import Status, StatusCode
48
49 log = logging.getLogger( "agent" )
50 tracer = trace.get_tracer( __name__ )
51
52 SCOPE = "https://ai.azure.com/.default" # model and toolbox audience; cognitiveservices gets 401
53 TOOLBOX_ENDPOINT = os.environ[ "TOOLBOX_ENDPOINT" ]
54 MODEL_BASE_URL = os.getenv( "MODEL_API_BASE_URL" )
55 MODEL_DEPLOYMENT = os.getenv( "AZURE_AI_MODEL_DEPLOYMENT_NAME" )
56 if bool ( MODEL_BASE_URL ) != bool ( MODEL_DEPLOYMENT ):
57 # One without the other would silently fall back to the Copilot default model.
58 raise RuntimeError ( "Set both MODEL_API_BASE_URL and AZURE_AI_MODEL_DEPLOYMENT_NAME, or neither." )
59 WRITABLE = Path(tempfile.gettempdir()) # hosted containers are read-only except the temp dir
60 SKILL_URI = re.compile( r "skill:// ([ A-Za-z0-9_- ] + ) /SKILL \. md" )
61 TURN_TIMEOUT = 300.0
62
63
64 @dataclass
65 class ToolCall :
66 """One toolbox call, emitted as function_call and function_call_output items."""
67
68 call_id: str
69 name: str
70 arguments: str
71 output: str
72
73
74 @dataclass
75 class Turn :
76 text: str
77 tool_calls: list[ToolCall] = field( default_factory = list )
78
79
80 class ToolboxError ( RuntimeError ):
81 """A toolbox JSON-RPC error; `consent_url` is set when a connection needs user consent."""
82
83 def __init__ (self, message: str , consent_url: str | None = None ) -> None :
84 super (). __init__ (message)
85 self .consent_url = consent_url
86
87
88 def _toolbox_error (error: dict[ str , Any]) -> ToolboxError:
89 message = str (error.get( "message" , "" ))
90 if error.get( "code" ) == - 32006 and "{" in message:
91 try : # -32006 wraps per-source errors as JSON after a human-readable prefix
92 for source in json.loads(message[message.index( "{" ) :]).get( "errors" , []):
93 nested = source.get( "error" ) or {}
94 if nested.get( "code" ) == "CONSENT_REQUIRED" :
95 url = nested.get( "message" )
96 name = source.get( "name" , "A toolbox connection" )
97 return ToolboxError( f " { name } needs consent: open { url } , sign in, then retry." , url)
98 except ( ValueError , AttributeError ):
99 pass
100 return ToolboxError( f "Toolbox error { error.get( 'code' ) } : { message or 'unknown' } " )
101
102
103 class _Bearer ( httpx . Auth ):
104 """Adds a token to every request; Azure Identity caches and renews it."""
105
106 def __init__ (self, credential: AsyncTokenCredential) -> None :
107 self ._credential = credential
108
109 async def async_auth_flow (self, request: httpx.Request):
110 request.headers[ "Authorization" ] = f "Bearer { ( await self ._credential.get_token( SCOPE )).token } "
111 yield request
112
113
114 class McpBridge :
115 """Request-scoped JSON-RPC client for the toolbox MCP endpoint; initialize is optional."""
116
117 def __init__ (self, credential: AsyncTokenCredential, call_id: str | None ) -> None :
118 headers = { "Foundry-Features" : "Toolboxes=V1Preview" }
119 if call_id: # forward the caller context unchanged; never fabricate one
120 headers[ "x-agent-foundry-call-id" ] = call_id
121 self ._http = httpx.AsyncClient( auth = _Bearer(credential), headers = headers, timeout = 60.0 )
122 self ._next_id = 0
123
124 async def __aenter__ (self) -> McpBridge:
125 return self
126
127 async def __aexit__ (self, * _exc: object ) -> None :
128 await self ._http.aclose()
129
130 async def rpc (self, method: str , params: dict[ str , Any], target: str = "" ) -> dict[ str , Any]:
131 """One JSON-RPC call in an MCP client span that records no arguments or results."""
132 self ._next_id += 1
133 payload = { "jsonrpc" : "2.0" , "id" : self ._next_id, "method" : method, "params" : params}
134 name = f " { method } { target } " .strip()
135 with tracer.start_as_current_span(
136 name, record_exception = False , set_status_on_exception = False
137 ) as span:
138 span.set_attribute( "mcp.method.name" , method)
139 if method == "tools/call" :
140 span.set_attribute( "gen_ai.tool.name" , target)
141 try :
142 response = await self ._http.post( TOOLBOX_ENDPOINT , json = payload)
143 response.raise_for_status()
144 body = response.json()
145 if "error" in body:
146 raise _toolbox_error(body[ "error" ])
147 except Exception as exc:
148 span.set_status(Status(StatusCode. ERROR ))
149 span.set_attribute( "error.type" , type (exc). __name__ )
150 raise
151 return body.get( "result" ) or {}
152
153
154 def _call_handler (toolbox: McpBridge, mcp_name: str , calls: list[ToolCall]):
155 async def handler (invocation: ToolInvocation) -> ToolResult:
156 arguments = invocation.arguments if isinstance (invocation.arguments, dict ) else {}
157 try :
158 result = await toolbox.rpc( "tools/call" , { "name" : mcp_name, "arguments" : arguments}, mcp_name)
159 except Exception as exc: # report to the model; it can retry or explain
160 output, outcome = str (exc), ToolResult( result_type = "failure" , error = str (exc))
161 else :
162 texts = [c.get( "text" , "" ) for c in result.get( "content" , []) if c.get( "type" ) == "text" ]
163 output = " \n " .join(t for t in texts if t) or json.dumps(result)
164 failed = bool (result.get( "isError" ))
165 outcome = ToolResult(
166 text_result_for_llm = output,
167 result_type = "failure" if failed else "success" ,
168 error = output if failed else None ,
169 )
170 calls.append(ToolCall(invocation.tool_call_id, mcp_name, json.dumps(arguments), output))
171 return outcome
172
173 return handler
174
175
176 def toolbox_tools (toolbox: McpBridge, listed: list[dict[ str , Any]], calls: list[ToolCall]) -> list[Tool]:
177 """Wrap each tools/list entry as an SDK tool; MCP-sourced names look like label___tool."""
178 tools: dict[ str , Tool] = {}
179 sources: dict[ str , str ] = {}
180 for spec in listed:
181 mcp_name = spec[ "name" ]
182 name = re.sub( r " [ .- ] " , "_" , mcp_name) # SDK tool names reject dots and hyphens
183 if name in sources:
184 # Dropping one would silently change the agent's capabilities; rename a tool instead.
185 raise ValueError ( f "Toolbox tools { sources[name] !r} and { mcp_name !r} both map to { name !r} ." )
186 sources[name] = mcp_name
187 tools[name] = Tool(
188 name = name,
189 description = spec.get( "description" ) or mcp_name,
190 parameters = spec.get( "inputSchema" ) or { "type" : "object" , "properties" : {}},
191 handler = _call_handler(toolbox, mcp_name, calls),
192 )
193 return list (tools.values())
194
195
196 async def load_skills (toolbox: McpBridge, skills_dir: Path) -> list[ str ]:
197 """Write each toolbox skill to <skills_dir>/<name>/SKILL.md for the SDK skill loader."""
198 names: list[ str ] = []
199 for resource in ( await toolbox.rpc( "resources/list" , {})).get( "resources" , []):
200 match = SKILL_URI .fullmatch(resource.get( "uri" , "" ))
201 if not match:
202 continue # skill://index.json, archives and other resources
203 name = match.group( 1 )
204 read = await toolbox.rpc( "resources/read" , { "uri" : resource[ "uri" ]}, name)
205 body = "" .join(c.get( "text" , "" ) for c in read.get( "contents" , []))
206 if not body.lstrip().startswith( "---" ):
207 description = json.dumps(resource.get( "description" ) or name)
208 body = f "--- \n name: { name }\n description: { description }\n --- \n\n{ body } "
209 (skills_dir / name).mkdir( parents = True , exist_ok = True )
210 (skills_dir / name / "SKILL.md" ).write_text(body, encoding = "utf-8" )
211 names.append(name)
212 return names
213
214
215 def model_provider (credential: AsyncTokenCredential) -> ProviderConfig:
216 async def refresh_token (_request: ProviderTokenArgs) -> str :
217 return ( await credential.get_token( SCOPE )).token
218
219 return {
220 "type" : "openai" ,
221 "base_url" : MODEL_BASE_URL ,
222 "wire_api" : "responses" ,
223 "bearer_token_provider" : refresh_token,
224 }
225
226
227 def least_privilege (tool_names: set[ str ], skills_dir: Path) -> Callable[[Any, Any], Any]:
228 """Approve this turn's toolbox tools and reads of its skills; reject everything else."""
229
230 def decide (request: Any, _invocation: Any) -> Any:
231 if isinstance (request, PermissionRequestCustomTool) and request.tool_name in tool_names:
232 return PermissionDecisionApproveOnce()
233 if isinstance (request, PermissionRequestRead):
234 if Path(request.resolved_path or request.path).resolve().is_relative_to(skills_dir):
235 return PermissionDecisionApproveOnce()
236 return PermissionDecisionReject( feedback = "Not permitted for this agent." )
237
238 return decide
239
240
241 def log_event (event: Any) -> None :
242 """Log safe identifiers only: never prompts, arguments, results or caller context."""
243 data = event.data
244 if event.type == SessionEventType. SESSION_ERROR :
245 log.error(
246 "session error type= %s code= %s status= %s request= %s " ,
247 data.error_type, data.error_code, data.status_code, data.service_request_id,
248 )
249 elif event.type == SessionEventType. TOOL_EXECUTION_START :
250 log.info( "tool start %s id= %s " , data.tool_name, data.tool_call_id)
251 elif event.type == SessionEventType. TOOL_EXECUTION_COMPLETE :
252 log.info( "tool done id= %s success= %s " , data.tool_call_id, data.success)
253
254
255 def new_client () -> CopilotClient:
256 """One Copilot runtime per process."""
257 options: dict[ str , Any] = {}
258 if not os.access(Path.home(), os. W_OK ): # session state defaults to ~/.copilot
259 options[ "base_directory" ] = str ( WRITABLE / "copilot" )
260 if endpoint := os.getenv( "COPILOT_OTLP_ENDPOINT" ):
261 options[ "telemetry" ] = {
262 "otlp_endpoint" : endpoint,
263 "otlp_protocol" : "http/protobuf" ,
264 "exporter_type" : "otlp-http" ,
265 "capture_content" : False ,
266 }
267 return CopilotClient( ** options)
268
269
270 async def run_turn (
271 client: CopilotClient, credential: AsyncTokenCredential, prompt: str , call_id: str | None = None
272 ) -> Turn:
273 """Run one request with its own toolbox connection, skills directory and session."""
274 calls: list[ToolCall] = []
275 headers = { "x-agent-foundry-call-id" : call_id} if call_id else None
276 options: dict[ str , Any] = {}
277 if MODEL_BASE_URL :
278 options = { "provider" : model_provider(credential), "model" : MODEL_DEPLOYMENT }
279 async with McpBridge(credential, call_id) as toolbox:
280 tools = toolbox_tools(toolbox, ( await toolbox.rpc( "tools/list" , {})).get( "tools" , []), calls)
281 with tempfile.TemporaryDirectory( prefix = "turn-" , dir = WRITABLE ) as workdir:
282 skills_dir = Path(workdir).resolve() / "skills"
283 await load_skills(toolbox, skills_dir)
284 async with await client.create_session(
285 ** options,
286 tools = tools,
287 skill_directories = [ str (skills_dir)],
288 working_directory = workdir,
289 on_permission_request = least_privilege({tool.name for tool in tools}, skills_dir),
290 on_event = log_event,
291 ) as session:
292 try :
293 reply = await session.send_and_wait(
294 prompt, request_headers = headers, timeout = TURN_TIMEOUT
295 )
296 except TimeoutError :
297 await session.abort() # the timeout alone leaves the turn running
298 raise
299 if reply is None or not reply.data.content:
300 raise RuntimeError ( "The session ended without an assistant message." )
301 return Turn(reply.data.content, calls)
302
303
304 async def main () -> None :
305 """Local smoke run: `az login`, set TOOLBOX_ENDPOINT, optionally the model pair."""
306 logging.basicConfig( level = logging. INFO )
307 async with DefaultAzureCredential() as credential, new_client() as client:
308 try :
309 turn = await run_turn(client, credential, "Which tools and skills can you use? Be brief." )
310 except ToolboxError as exc:
311 if exc.consent_url is None :
312 raise
313 print (exc)
314 return
315 for call in turn.tool_calls:
316 print ( f "tool { call.name } : { len (call.output) } chars" )
317 print (turn.text)
318
319
320 if __name__ == "__main__" :
321 asyncio.run(main())