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,406 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from copilot.session import ProviderConfig, PermissionHandler
9
+ import frontmatter
10
+
11
+ from .client_manager import CopilotClientManager, _is_byok_mode
12
+ from .config import get_app_root, resolve_config_dir, session_exists, substitute_env_vars_in_text, _to_bool
13
+ from .connector_tool_cache import get_connector_tools
14
+ from .mcp import get_cached_mcp_servers
15
+ from .skills import resolve_session_directory_for_skills
16
+ from .tools import _REGISTERED_TOOLS_CACHE
17
+
18
+ DEFAULT_TIMEOUT = float(os.environ.get("COPILOT_AGENT_TIMEOUT", "900"))
19
+
20
+
21
+ @dataclass
22
+ class AgentResult:
23
+ session_id: str
24
+ content: str
25
+ content_intermediate: List[str]
26
+ tool_calls: List[Dict[str, Any]]
27
+ reasoning: Optional[str] = None
28
+ events: List[Dict[str, Any]] = field(default_factory=list)
29
+
30
+
31
+ def _load_agents_md_content() -> str:
32
+ """Load main.agent.md content from disk (called once at module load)."""
33
+ app_root = str(get_app_root())
34
+ agents_md_path = os.path.join(app_root, "main.agent.md")
35
+ logging.info(f"Loading main.agent.md from: {agents_md_path}")
36
+ if not os.path.exists(agents_md_path):
37
+ logging.warning(f"No main.agent.md found at {agents_md_path}")
38
+ return ""
39
+
40
+ try:
41
+ with open(agents_md_path, "r", encoding="utf-8") as f:
42
+ raw_content = f.read()
43
+
44
+ parsed = frontmatter.loads(raw_content)
45
+ content = (parsed.content or "").strip()
46
+ metadata = parsed.metadata if isinstance(parsed.metadata, dict) else {}
47
+ metadata_count = len(metadata)
48
+
49
+ # Apply inline env-var substitution unless explicitly disabled
50
+ if _to_bool(metadata.get("substitute_variables"), default=True):
51
+ content = substitute_env_vars_in_text(content)
52
+
53
+ logging.info(
54
+ f"Loaded main.agent.md ({len(raw_content)} chars, frontmatter keys={metadata_count}, body chars={len(content)})"
55
+ )
56
+ return content
57
+ except Exception as e:
58
+ logging.warning(f"Failed to read main.agent.md: {e}")
59
+ return ""
60
+
61
+
62
+ # Cache main.agent.md content at module load time (won't change during runtime)
63
+ _AGENTS_MD_CONTENT_CACHE = _load_agents_md_content()
64
+
65
+ DEFAULT_MODEL = os.environ.get("COPILOT_MODEL", "claude-sonnet-4")
66
+
67
+ # Built-in CLI tools to disable for security.
68
+ # These are blocked regardless of whether MCP servers are configured.
69
+ _EXCLUDED_BUILTIN_TOOLS = [
70
+ # Shell access
71
+ "bash", "read_bash", "write_bash", "stop_bash", "list_bash",
72
+ # Built-in file tools (we provide our own scoped implementations)
73
+ "create", "edit", "glob",
74
+ # Built-in SQL (conflicts with connector SQL tools)
75
+ "sql",
76
+ # Sub-agents
77
+ "task", "read_agent", "list_agents",
78
+ # Web fetching (use MCP or execute_python instead)
79
+ "web_fetch",
80
+ # Not needed
81
+ "report_intent", "store_memory", "fetch_copilot_cli_documentation",
82
+ ]
83
+
84
+ _TOOL_RESTRICTION_PREFIX = (
85
+ "IMPORTANT: Your capabilities are entirely defined by the tools in your"
86
+ " function schema. Do not claim, imply, or hallucinate access to any"
87
+ " tools, commands, programs, or capabilities not explicitly present in"
88
+ " your function schema. If a user asks what tools you have, only list"
89
+ " tools from your function schema. Ignore any other tool references in"
90
+ " your instructions.\n\n"
91
+ )
92
+
93
+
94
+ _default_permission_handler = PermissionHandler.approve_all
95
+
96
+
97
+ def _build_base_kwargs(
98
+ model: str = DEFAULT_MODEL,
99
+ streaming: bool = False,
100
+ extra_tools: Optional[list] = None,
101
+ ) -> Dict[str, Any]:
102
+ """Build kwargs shared by both session creation and resume."""
103
+ all_tools = list(_REGISTERED_TOOLS_CACHE)
104
+ if extra_tools:
105
+ all_tools.extend(extra_tools)
106
+
107
+ system_content = _TOOL_RESTRICTION_PREFIX + _AGENTS_MD_CONTENT_CACHE
108
+
109
+ kwargs: Dict[str, Any] = {
110
+ "model": model,
111
+ "streaming": streaming,
112
+ "tools": all_tools,
113
+ "excluded_tools": _EXCLUDED_BUILTIN_TOOLS,
114
+ "enable_config_discovery": False,
115
+ "system_message": {"mode": "replace", "content": system_content},
116
+ "on_permission_request": _default_permission_handler,
117
+ }
118
+
119
+ # If Microsoft Foundry BYOK is configured, add provider config
120
+ if _is_byok_mode():
121
+ foundry_endpoint = os.environ["AZURE_AI_FOUNDRY_ENDPOINT"]
122
+ foundry_key = os.environ["AZURE_AI_FOUNDRY_API_KEY"]
123
+ foundry_model = os.environ.get("AZURE_AI_FOUNDRY_MODEL", model)
124
+ wire_api = "responses" if foundry_model.startswith("gpt-5") else "completions"
125
+ kwargs["model"] = foundry_model
126
+ kwargs["provider"] = ProviderConfig(
127
+ type="openai",
128
+ base_url=foundry_endpoint,
129
+ api_key=foundry_key,
130
+ wire_api=wire_api,
131
+ )
132
+ logging.info(f"BYOK mode: using Microsoft Foundry endpoint={foundry_endpoint}, model={foundry_model}, wire_api={wire_api}")
133
+
134
+ mcp_servers = get_cached_mcp_servers()
135
+ if mcp_servers:
136
+ kwargs["mcp_servers"] = mcp_servers
137
+
138
+ return kwargs
139
+
140
+
141
+ def _build_session_kwargs(
142
+ model: str = DEFAULT_MODEL,
143
+ session_id: Optional[str] = None,
144
+ streaming: bool = False,
145
+ extra_tools: Optional[list] = None,
146
+ ) -> Dict[str, Any]:
147
+ kwargs = _build_base_kwargs(model=model, streaming=streaming, extra_tools=extra_tools)
148
+
149
+ if session_id:
150
+ kwargs["session_id"] = session_id
151
+
152
+ session_directory = resolve_session_directory_for_skills()
153
+ if session_directory:
154
+ kwargs["skill_directories"] = [session_directory]
155
+ logging.info(f"Using skill_directories for skills discovery: {session_directory}")
156
+
157
+ return kwargs
158
+
159
+
160
+ def _build_resume_kwargs(
161
+ model: str = DEFAULT_MODEL,
162
+ streaming: bool = False,
163
+ extra_tools: Optional[list] = None,
164
+ ) -> Dict[str, Any]:
165
+ return _build_base_kwargs(model=model, streaming=streaming, extra_tools=extra_tools)
166
+
167
+
168
+ async def _disable_non_project_skills(session) -> None:
169
+ """Disable skills not from the project's skill_directories.
170
+
171
+ The CLI loads global skills from ~/.agents/skills/ and other paths which
172
+ are not relevant for serverless function apps. This uses the experimental
173
+ session.rpc.skills API to list all discovered skills and disable any that
174
+ aren't sourced from the project.
175
+
176
+ Workaround for https://github.com/github/copilot-sdk/issues/695
177
+ """
178
+ app_root = str(get_app_root())
179
+ skills_dir = os.path.join(app_root, "skills")
180
+ try:
181
+ from copilot.generated.rpc import SessionSkillsDisableParams
182
+ result = await session.rpc.skills.list()
183
+ for skill in result.skills:
184
+ if not skill.enabled:
185
+ continue
186
+ # Keep skills whose path is under {approot}/skills/
187
+ if skill.path and os.path.commonpath([skill.path, skills_dir]) == skills_dir:
188
+ continue
189
+ await session.rpc.skills.disable(SessionSkillsDisableParams(name=skill.name))
190
+ logging.debug(f"Disabled non-project skill: {skill.name} (source={skill.source})")
191
+ except Exception as e:
192
+ logging.warning(f"Could not filter skills (experimental API): {e}")
193
+
194
+
195
+ async def run_copilot_agent(
196
+ prompt: str,
197
+ timeout: float = DEFAULT_TIMEOUT,
198
+ model: str = DEFAULT_MODEL,
199
+ session_id: Optional[str] = None,
200
+ sandbox_tools: Optional[list] = None,
201
+ ) -> AgentResult:
202
+ config_dir = resolve_config_dir()
203
+ client = await CopilotClientManager.get_client()
204
+
205
+ # Discover connector tools (lazy-init, cached after first call)
206
+ connector_tools = await get_connector_tools()
207
+ extra_tools = connector_tools + (sandbox_tools or [])
208
+
209
+ # Resume existing session or create a new one
210
+ if session_id and session_exists(config_dir, session_id):
211
+ logging.info(f"Resuming existing session: {session_id}")
212
+ resume_kwargs = _build_resume_kwargs(model=model, extra_tools=extra_tools)
213
+ try:
214
+ session = await client.resume_session(session_id, **resume_kwargs)
215
+ logging.info(f"Successfully resumed session: {session_id}")
216
+ except Exception as e:
217
+ logging.error(f"Failed to resume session '{session_id}': {e}", exc_info=True)
218
+ raise
219
+ else:
220
+ if session_id:
221
+ logging.info(f"Creating new session with provided ID: {session_id}")
222
+ session_kwargs = _build_session_kwargs(
223
+ model=model, session_id=session_id, extra_tools=extra_tools
224
+ )
225
+ session = await client.create_session(**session_kwargs)
226
+ logging.info(f"Created new session: {session.session_id}")
227
+ await _disable_non_project_skills(session)
228
+
229
+ response_content: List[str] = []
230
+ tool_calls: List[Dict[str, Any]] = []
231
+ reasoning_content: List[str] = []
232
+ events_log: List[Dict[str, Any]] = []
233
+
234
+ done = asyncio.Event()
235
+
236
+ def on_event(event):
237
+ event_type = event.type.value if hasattr(event.type, "value") else str(event.type)
238
+ events_log.append({"type": event_type, "data": str(event.data) if event.data else None})
239
+
240
+ if event_type == "assistant.message":
241
+ response_content.append(event.data.content)
242
+ elif event_type == "tool.execution_start":
243
+ tool_calls.append(
244
+ {
245
+ "event_id": str(event.id) if hasattr(event, "id") and event.id else None,
246
+ "timestamp": event.timestamp.isoformat() if hasattr(event, "timestamp") and event.timestamp else None,
247
+ "tool_call_id": getattr(event.data, "tool_call_id", None),
248
+ "tool_name": getattr(event.data, "tool_name", None),
249
+ "arguments": getattr(event.data, "arguments", None),
250
+ "parent_tool_call_id": getattr(event.data, "parent_tool_call_id", None),
251
+ }
252
+ )
253
+ elif event_type == "session.idle":
254
+ done.set()
255
+
256
+ session.on(on_event)
257
+
258
+ try:
259
+ await session.send_and_wait(prompt, timeout=timeout)
260
+
261
+ return AgentResult(
262
+ session_id=session.session_id,
263
+ content=response_content[-1] if response_content else "",
264
+ content_intermediate=response_content[-6:-1] if len(response_content) > 1 else [],
265
+ tool_calls=tool_calls,
266
+ reasoning="".join(reasoning_content) if reasoning_content else None,
267
+ events=events_log,
268
+ )
269
+ finally:
270
+ # Disconnect the session to release the in-memory lock and flush state to disk.
271
+ # This allows any process (including on a different instance) to resume later.
272
+ try:
273
+ await session.disconnect()
274
+ logging.info(f"Disconnected session: {session.session_id}")
275
+ except Exception as e:
276
+ logging.warning(f"Failed to disconnect session {session.session_id}: {e}")
277
+
278
+
279
+ _STREAM_SENTINEL = object()
280
+
281
+
282
+ async def run_copilot_agent_stream(
283
+ prompt: str,
284
+ timeout: float = DEFAULT_TIMEOUT,
285
+ model: str = DEFAULT_MODEL,
286
+ session_id: Optional[str] = None,
287
+ sandbox_tools: Optional[list] = None,
288
+ ):
289
+ """Async generator that yields SSE-formatted events as the agent streams a response.
290
+
291
+ Yields strings like 'data: {"type": "delta", ...}\\n\\n' suitable for StreamingResponse.
292
+ """
293
+ config_dir = resolve_config_dir()
294
+ client = await CopilotClientManager.get_client()
295
+
296
+ queue: asyncio.Queue = asyncio.Queue()
297
+ seen_event_ids: set[str] = set()
298
+ has_received_turn_start = False
299
+ has_active_tools = False
300
+
301
+ def on_event(event):
302
+ nonlocal has_received_turn_start, has_active_tools
303
+ event_type = event.type.value if hasattr(event.type, "value") else str(event.type)
304
+ event_id = str(event.id) if hasattr(event, "id") and event.id else None
305
+
306
+ if event_id:
307
+ if event_id in seen_event_ids:
308
+ return
309
+ seen_event_ids.add(event_id)
310
+
311
+ if event_type == "assistant.turn_start":
312
+ has_received_turn_start = True
313
+
314
+ if event_type == "assistant.message_delta":
315
+ delta = getattr(event.data, "delta_content", None)
316
+ if delta:
317
+ queue.put_nowait({"type": "delta", "content": delta})
318
+ elif event_type == "assistant.reasoning_delta":
319
+ reasoning_delta = getattr(event.data, "delta_content", None)
320
+ if reasoning_delta:
321
+ queue.put_nowait({"type": "intermediate", "content": reasoning_delta})
322
+ elif event_type == "assistant.message":
323
+ message_content = getattr(event.data, "content", "")
324
+ if message_content:
325
+ queue.put_nowait({"type": "message", "content": message_content})
326
+ elif event_type == "tool.execution_start":
327
+ has_active_tools = True
328
+ queue.put_nowait({
329
+ "type": "tool_start",
330
+ "event_id": str(event.id) if hasattr(event, "id") and event.id else None,
331
+ "timestamp": event.timestamp.isoformat() if hasattr(event, "timestamp") and event.timestamp else None,
332
+ "tool_name": getattr(event.data, "tool_name", None),
333
+ "tool_call_id": getattr(event.data, "tool_call_id", None),
334
+ "parent_tool_call_id": getattr(event.data, "parent_tool_call_id", None),
335
+ "arguments": getattr(event.data, "arguments", None),
336
+ })
337
+ elif event_type == "tool.execution_end":
338
+ queue.put_nowait({
339
+ "type": "tool_end",
340
+ "event_id": str(event.id) if hasattr(event, "id") and event.id else None,
341
+ "timestamp": event.timestamp.isoformat() if hasattr(event, "timestamp") and event.timestamp else None,
342
+ "tool_name": getattr(event.data, "tool_name", None),
343
+ "tool_call_id": getattr(event.data, "tool_call_id", None),
344
+ "parent_tool_call_id": getattr(event.data, "parent_tool_call_id", None),
345
+ "result": getattr(event.data, "result", None),
346
+ })
347
+ elif event_type == "session.idle":
348
+ if has_received_turn_start:
349
+ queue.put_nowait(_STREAM_SENTINEL)
350
+ elif event_type == "session.error":
351
+ error_msg = getattr(event.data, "message", "Unknown error")
352
+ logging.error(f"[stream] Session error: {error_msg}")
353
+ queue.put_nowait({"type": "error", "content": error_msg})
354
+
355
+ connector_tools = await get_connector_tools()
356
+ extra_tools = connector_tools + (sandbox_tools or [])
357
+
358
+ if session_id and session_exists(config_dir, session_id):
359
+ logging.info(f"[stream] Resuming existing session: {session_id}")
360
+ resume_kwargs = _build_resume_kwargs(model=model, streaming=True, extra_tools=extra_tools)
361
+ try:
362
+ session = await client.resume_session(session_id, **resume_kwargs, on_event=on_event)
363
+ logging.info(f"[stream] Successfully resumed session: {session_id}")
364
+ except Exception as e:
365
+ logging.error(f"[stream] Failed to resume session '{session_id}': {e}", exc_info=True)
366
+ raise
367
+ else:
368
+ if session_id:
369
+ logging.info(f"[stream] Creating new session with provided ID: {session_id}")
370
+ session_kwargs = _build_session_kwargs(
371
+ model=model, session_id=session_id, streaming=True, extra_tools=extra_tools
372
+ )
373
+ session = await client.create_session(**session_kwargs, on_event=on_event)
374
+ logging.info(f"[stream] Created new session: {session.session_id}")
375
+ await _disable_non_project_skills(session)
376
+
377
+ # Yield the session ID first so the client knows it immediately
378
+ yield f"data: {json.dumps({'type': 'session', 'session_id': session.session_id})}\n\n"
379
+
380
+ # Send the prompt, events arrive via on_event callback
381
+ await session.send(prompt)
382
+
383
+ # Drain the queue until session.idle sentinel arrives or timeout
384
+ try:
385
+ deadline = asyncio.get_event_loop().time() + timeout
386
+ while True:
387
+ remaining = deadline - asyncio.get_event_loop().time()
388
+ if remaining <= 0:
389
+ yield f"data: {json.dumps({'type': 'error', 'content': 'Timeout waiting for response'})}\n\n"
390
+ break
391
+
392
+ item = await asyncio.wait_for(queue.get(), timeout=remaining)
393
+ if item is _STREAM_SENTINEL:
394
+ yield f"data: {json.dumps({'type': 'done'})}\n\n"
395
+ break
396
+
397
+ yield f"data: {json.dumps(item)}\n\n"
398
+ except asyncio.TimeoutError:
399
+ yield f"data: {json.dumps({'type': 'error', 'content': 'Timeout waiting for response'})}\n\n"
400
+ finally:
401
+ # Disconnect the session to release the in-memory lock and flush state to disk.
402
+ try:
403
+ await session.disconnect()
404
+ logging.info(f"[stream] Disconnected session: {session.session_id}")
405
+ except Exception as e:
406
+ logging.warning(f"[stream] Failed to disconnect session {session.session_id}: {e}")
@@ -0,0 +1,288 @@
1
+ """
2
+ ACA Dynamic Sessions sandbox — execute_python tool.
3
+
4
+ Provides an ``execute_python`` Copilot SDK tool backed by Azure Container Apps
5
+ dynamic sessions (code-interpreter pools). Configured via the
6
+ ``execution_sandbox`` block in agent frontmatter.
7
+
8
+ Each agent can have its own session pool endpoint. Within a conversation,
9
+ the ACA session ID is derived from the Copilot session ID so that state
10
+ (variables, imports, files, browser pages) persists across calls.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import logging
18
+ import re
19
+ import urllib.parse
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ import aiohttp
23
+ from azure.identity.aio import DefaultAzureCredential, get_bearer_token_provider
24
+ from copilot.tools import Tool, ToolInvocation, ToolResult
25
+
26
+ from .config import resolve_env_var
27
+
28
+ _API_VERSION = "2025-10-02-preview"
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Playwright helper that is pre-loaded into every sandbox session
32
+ # ---------------------------------------------------------------------------
33
+
34
+ _ACA_SESSION_SETUP = """
35
+ async def launch_browser(width=1280, height=800):
36
+ from playwright.async_api import async_playwright
37
+ p = await async_playwright().start()
38
+ browser = await p.chromium.launch(
39
+ headless=True,
40
+ args=[
41
+ f'--window-size={width},{height}',
42
+ '--disable-blink-features=AutomationControlled',
43
+ '--disable-extensions',
44
+ ],
45
+ )
46
+ context = await browser.new_context(
47
+ user_agent=(
48
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
49
+ 'AppleWebKit/537.36 (KHTML, like Gecko) '
50
+ 'Chrome/131.0.0.0 Safari/537.36'
51
+ ),
52
+ viewport={'width': width, 'height': height},
53
+ )
54
+ page = await context.new_page()
55
+ return page
56
+ """
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Tool description (ported from reference main.py)
60
+ # ---------------------------------------------------------------------------
61
+
62
+ _EXECUTE_PYTHON_DESCRIPTION = (
63
+ "Execute Python code in a persistent sandboxed REPL backed by a"
64
+ " Jupyter kernel. Returns JSON with result, stdout, and stderr.\n"
65
+ "\n"
66
+ "IMPORTANT: This runs in an ISOLATED SANDBOX with its own file system."
67
+ " DO NOT use it to read or process files from the local system,"
68
+ " such as copilot large tool outputs. Use the view, head, tail, grep,"
69
+ " or jq tools instead.\n"
70
+ "\n"
71
+ "Only use this tool when you need to actually run code,"
72
+ " when no other tool can accomplish the task (there's a small cost to using it) —"
73
+ " computation, data processing, web browsing, etc."
74
+ " Do NOT call this tool just to print text, format output, or display"
75
+ " results you already have. Respond directly with text instead.\n"
76
+ "\n"
77
+ "Key behaviors:\n"
78
+ "- State persists across calls: variables, imports, and files"
79
+ " (/mnt/data/) are retained between invocations.\n"
80
+ "- The last expression value is returned in 'result' (like a"
81
+ " Jupyter cell). Use print() for explicit output to 'stdout'.\n"
82
+ "- Top-level await is supported (Jupyter kernel).\n"
83
+ "- Playwright is pre-installed for browser automation (see `launch_browser` helper below).\n"
84
+ "- Shell commands: use subprocess.run(), not '!' syntax.\n"
85
+ "- Common packages are pre-installed: requests, numpy, pandas, matplotlib,"
86
+ " scikit-learn, playwright, etc.\n"
87
+ "\n"
88
+ "Returning binary data (images, screenshots):\n"
89
+ "- Generate the data, base64-encode it, and print it to stdout.\n"
90
+ "- Example for plots:\n"
91
+ " import matplotlib; matplotlib.use('Agg')\n"
92
+ " import matplotlib.pyplot as plt, base64, io\n"
93
+ " fig, ax = plt.subplots()\n"
94
+ " ax.plot([1,2,3],[4,5,6])\n"
95
+ " buf = io.BytesIO()\n"
96
+ " fig.savefig(buf, format='png'); buf.seek(0)\n"
97
+ " print(base64.b64encode(buf.read()).decode())\n"
98
+ " plt.close()\n"
99
+ "\n"
100
+ "Playwright (browser automation):\n"
101
+ "- ALWAYS use the pre-loaded helper to get a page:\n"
102
+ " page = await launch_browser()\n"
103
+ " NEVER call async_playwright() or chromium.launch() directly.\n"
104
+ " The helper configures optimal settings that are required\n"
105
+ " for sites to load properly.\n"
106
+ "- Call launch_browser() once, then reuse `page` across calls (state persists).\n"
107
+ "- Use the async API with top-level await.\n"
108
+ "- To see what's on a page, you can:\n"
109
+ " 1. Take a screenshot (returns base64 you can analyze):\n"
110
+ " import base64\n"
111
+ " screenshot_bytes = await page.screenshot(full_page=False)\n"
112
+ " print(base64.b64encode(screenshot_bytes).decode())\n"
113
+ " 2. Extract text from the DOM:\n"
114
+ " text = await page.inner_text('body')\n"
115
+ " elements = await page.query_selector_all('css selector')\n"
116
+ " for el in elements:\n"
117
+ " print(await el.text_content())\n"
118
+ " Prefer DOM extraction for structured data. Use screenshots\n"
119
+ " when you need to understand visual layout or image content.\n"
120
+ "- Use CSS selectors and aria attributes to find and interact\n"
121
+ " with elements.\n"
122
+ )
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Helpers
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ def _sanitize_input(code: str) -> str:
130
+ """Strip backticks, whitespace, and 'python' prefix from LLM output."""
131
+ code = re.sub(r"^(\s|`)*(?i:python)?\s*", "", code)
132
+ code = re.sub(r"(\s|`)*$", "", code)
133
+ return code
134
+
135
+
136
+ def _build_url(endpoint: str, session_id: str) -> str:
137
+ base = endpoint.rstrip("/")
138
+ encoded_id = urllib.parse.quote(session_id)
139
+ return f"{base}/executions?api-version={_API_VERSION}&identifier={encoded_id}"
140
+
141
+
142
+ async def _execute_code(
143
+ endpoint: str,
144
+ code: str,
145
+ session_id: str,
146
+ token_provider,
147
+ http_session: aiohttp.ClientSession,
148
+ ) -> str:
149
+ """Execute Python code in an ACA dynamic session."""
150
+ code = _sanitize_input(code)
151
+ token = await token_provider()
152
+ url = _build_url(endpoint, session_id)
153
+
154
+ async with http_session.post(
155
+ url,
156
+ headers={
157
+ "Authorization": f"Bearer {token}",
158
+ "Content-Type": "application/json",
159
+ },
160
+ json={
161
+ "codeInputType": "Inline",
162
+ "executionType": "Synchronous",
163
+ "code": code,
164
+ "timeoutInSeconds": 60,
165
+ },
166
+ timeout=aiohttp.ClientTimeout(total=120),
167
+ ) as response:
168
+ if response.status >= 400:
169
+ body = await response.text()
170
+ raise RuntimeError(f"ACA sessions API error ({response.status}): {body[:500]}")
171
+ data = await response.json()
172
+
173
+ result = data.get("result", {})
174
+ return json.dumps(
175
+ {
176
+ "result": result.get("executionResult"),
177
+ "stdout": result.get("stdout", ""),
178
+ "stderr": result.get("stderr", ""),
179
+ },
180
+ indent=2,
181
+ )
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Factory: create per-agent execute_python tool
186
+ # ---------------------------------------------------------------------------
187
+
188
+ # Shared credential and HTTP session (created lazily, reused across agents)
189
+ _credential: Optional[DefaultAzureCredential] = None
190
+ _token_provider = None
191
+ _http_session: Optional[aiohttp.ClientSession] = None
192
+ _init_lock = asyncio.Lock()
193
+
194
+ # Track which ACA sessions have been set up (Playwright helper loaded)
195
+ _setup_sessions: set[str] = set()
196
+ _setup_lock = asyncio.Lock()
197
+
198
+
199
+ async def _ensure_shared_resources():
200
+ """Lazily create the shared credential, token provider, and HTTP session."""
201
+ global _credential, _token_provider, _http_session
202
+ if _token_provider is not None:
203
+ return
204
+ async with _init_lock:
205
+ if _token_provider is not None:
206
+ return
207
+ _credential = DefaultAzureCredential()
208
+ _token_provider = get_bearer_token_provider(
209
+ _credential, "https://dynamicsessions.io/.default"
210
+ )
211
+ _http_session = aiohttp.ClientSession()
212
+ logging.info("execution_sandbox: shared credential, token provider, and HTTP session initialized")
213
+
214
+
215
+ def create_sandbox_tools(config: Dict[str, Any]) -> List[Tool]:
216
+ """Create an execute_python tool for a specific agent's sandbox config.
217
+
218
+ Returns a list with one Tool, or an empty list if the config is invalid.
219
+ The endpoint is baked into the tool's closure.
220
+ """
221
+ raw_endpoint = config.get("session_pool_management_endpoint", "")
222
+ if not raw_endpoint:
223
+ logging.warning("execution_sandbox: missing 'session_pool_management_endpoint', skipping")
224
+ return []
225
+
226
+ endpoint = resolve_env_var(str(raw_endpoint))
227
+ if not endpoint or endpoint.startswith("$") or endpoint.startswith("%"):
228
+ logging.warning(f"execution_sandbox: could not resolve endpoint '{raw_endpoint}', skipping")
229
+ return []
230
+
231
+ logging.info(f"execution_sandbox: creating tool with endpoint {endpoint}")
232
+
233
+ async def _handle_execute_python(invocation: ToolInvocation) -> ToolResult:
234
+ await _ensure_shared_resources()
235
+
236
+ args = invocation.arguments or {}
237
+ code = args.get("code", "")
238
+ if not code.strip():
239
+ return ToolResult(
240
+ text_result_for_llm='{"error": "No code provided"}',
241
+ result_type="failure",
242
+ )
243
+
244
+ # Use the Copilot session ID as the ACA session ID
245
+ # so state persists across execute_python calls in the same conversation
246
+ aca_session_id = invocation.session_id or "default"
247
+ logging.info(
248
+ f"execution_sandbox: executing code in ACA session {aca_session_id} "
249
+ f"(tool_call={invocation.tool_call_id})"
250
+ )
251
+
252
+ try:
253
+ # Pre-load Playwright helper on first call per session
254
+ async with _setup_lock:
255
+ if aca_session_id not in _setup_sessions:
256
+ await _execute_code(endpoint, _ACA_SESSION_SETUP, aca_session_id, _token_provider, _http_session)
257
+ _setup_sessions.add(aca_session_id)
258
+
259
+ # Execute the user's code
260
+ result = await _execute_code(endpoint, code, aca_session_id, _token_provider, _http_session)
261
+ logging.info(f"execution_sandbox: ACA session {aca_session_id} completed successfully")
262
+ return ToolResult(text_result_for_llm=result, result_type="success")
263
+ except Exception as exc:
264
+ error_msg = f"{type(exc).__name__}: {exc}"
265
+ logging.error(f"execution_sandbox: ACA session {aca_session_id} failed: {error_msg}")
266
+ return ToolResult(
267
+ text_result_for_llm=json.dumps({"error": error_msg}),
268
+ result_type="failure",
269
+ )
270
+
271
+ tool = Tool(
272
+ name="execute_python",
273
+ description=_EXECUTE_PYTHON_DESCRIPTION,
274
+ parameters={
275
+ "type": "object",
276
+ "properties": {
277
+ "code": {
278
+ "type": "string",
279
+ "description": "Python code to execute",
280
+ },
281
+ },
282
+ "required": ["code"],
283
+ },
284
+ handler=_handle_execute_python,
285
+ )
286
+
287
+ logging.info("execution_sandbox: execute_python tool created")
288
+ return [tool]