azurefunctions-agents-runtime 0.0.0.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- azure_functions_agents/__init__.py +20 -0
- azure_functions_agents/app.py +720 -0
- azure_functions_agents/arm.py +95 -0
- azure_functions_agents/client_manager.py +84 -0
- azure_functions_agents/config.py +191 -0
- azure_functions_agents/connector_tool_cache.py +124 -0
- azure_functions_agents/connector_tools.py +267 -0
- azure_functions_agents/connectors.py +460 -0
- azure_functions_agents/mcp.py +87 -0
- azure_functions_agents/public/index.html +1504 -0
- azure_functions_agents/runner.py +406 -0
- azure_functions_agents/sandbox.py +288 -0
- azure_functions_agents/skills.py +24 -0
- azure_functions_agents/tools.py +316 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/METADATA +386 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/RECORD +20 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/WHEEL +5 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/licenses/LICENSE.md +21 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/top_level.txt +2 -0
- copilot_functions/__init__.py +3 -0
|
@@ -0,0 +1,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]
|