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,720 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Azure Functions + GitHub Copilot SDK — app factory.
|
|
3
|
+
|
|
4
|
+
Call ``create_function_app()`` to build a fully-configured FunctionApp
|
|
5
|
+
with HTTP routes, MCP tool, and dynamic triggers from agent markdown files.
|
|
6
|
+
|
|
7
|
+
Agent files:
|
|
8
|
+
- ``main.agent.md`` — primary agent (chat endpoints, MCP, UI). Optional.
|
|
9
|
+
- ``<name>.agent.md`` — triggered agents with exactly one trigger each.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import glob
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
import azure.functions as func
|
|
20
|
+
import frontmatter
|
|
21
|
+
|
|
22
|
+
from .config import get_app_root, set_app_root, resolve_env_var, substitute_env_vars_in_text, _to_bool
|
|
23
|
+
from .connector_tool_cache import configure_connector_tools
|
|
24
|
+
from .runner import run_copilot_agent, run_copilot_agent_stream
|
|
25
|
+
from .sandbox import create_sandbox_tools
|
|
26
|
+
from azurefunctions.extensions.http.fastapi import Request, Response, StreamingResponse
|
|
27
|
+
|
|
28
|
+
_MCP_AGENT_TOOL_PROPERTIES = json.dumps(
|
|
29
|
+
[
|
|
30
|
+
{
|
|
31
|
+
"propertyName": "prompt",
|
|
32
|
+
"propertyType": "string",
|
|
33
|
+
"description": "Prompt text sent to the agent.",
|
|
34
|
+
"isRequired": True,
|
|
35
|
+
"isArray": False,
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Helpers
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def _load_agent_file(path: Path) -> Optional[Dict[str, Any]]:
|
|
46
|
+
"""Parse an agent markdown file and return its metadata + content.
|
|
47
|
+
|
|
48
|
+
Returns a dict with 'metadata' (frontmatter dict) and 'content' (body str),
|
|
49
|
+
or None if the file doesn't exist or can't be parsed.
|
|
50
|
+
"""
|
|
51
|
+
if not path.exists():
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
raw = path.read_text(encoding="utf-8")
|
|
55
|
+
parsed = frontmatter.loads(raw)
|
|
56
|
+
metadata = parsed.metadata if isinstance(parsed.metadata, dict) else {}
|
|
57
|
+
content = (parsed.content or "").strip()
|
|
58
|
+
|
|
59
|
+
# Apply inline env-var substitution unless explicitly disabled
|
|
60
|
+
if _to_bool(metadata.get("substitute_variables"), default=True):
|
|
61
|
+
content = substitute_env_vars_in_text(content)
|
|
62
|
+
|
|
63
|
+
return {"metadata": metadata, "content": content}
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
logging.warning(f"Failed to parse {path.name}: {exc}")
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _safe_mcp_tool_name(raw_name: str) -> str:
|
|
70
|
+
normalized = re.sub(r"[^a-zA-Z0-9_]", "_", raw_name).strip("_").lower()
|
|
71
|
+
if not normalized:
|
|
72
|
+
return "agent_chat"
|
|
73
|
+
if normalized[0].isdigit():
|
|
74
|
+
return f"agent_{normalized}"
|
|
75
|
+
return normalized
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _extract_mcp_session_id(payload: Dict[str, Any]) -> str | None:
|
|
79
|
+
"""Extract MCP session id from top-level context payload only."""
|
|
80
|
+
value = payload.get("sessionId") or payload.get("sessionid")
|
|
81
|
+
if isinstance(value, str) and value.strip():
|
|
82
|
+
return value.strip()
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _safe_function_name(raw_name: str) -> str:
|
|
87
|
+
name = re.sub(r"[^a-zA-Z0-9_]", "_", raw_name).strip("_")
|
|
88
|
+
if not name:
|
|
89
|
+
return "agent_function"
|
|
90
|
+
if name[0].isdigit():
|
|
91
|
+
return f"fn_{name}"
|
|
92
|
+
return name
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _normalize_timer_schedule(schedule: str) -> str:
|
|
96
|
+
"""Accept 5-part cron by prepending seconds; keep 6-part schedules unchanged."""
|
|
97
|
+
schedule_parts = schedule.strip().split()
|
|
98
|
+
if len(schedule_parts) == 5:
|
|
99
|
+
return f"0 {schedule.strip()}"
|
|
100
|
+
return schedule.strip()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# _to_bool imported from .config
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _resolve_trigger_params(trigger_params: Dict[str, Any]) -> Dict[str, Any]:
|
|
107
|
+
"""Resolve env vars on all string values in trigger params."""
|
|
108
|
+
resolved = {}
|
|
109
|
+
for key, value in trigger_params.items():
|
|
110
|
+
if isinstance(value, str):
|
|
111
|
+
resolved[key] = resolve_env_var(value)
|
|
112
|
+
else:
|
|
113
|
+
resolved[key] = value
|
|
114
|
+
return resolved
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Triggered agent registration (*.agent.md files)
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def _register_triggered_agents(app: func.FunctionApp, app_root: Path) -> None:
|
|
122
|
+
"""Discover and register triggered agents from *.agent.md files."""
|
|
123
|
+
agent_files = sorted(glob.glob(str(app_root / "*.agent.md")))
|
|
124
|
+
if not agent_files:
|
|
125
|
+
logging.info("No agent files found.")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
connectors_instance = None # Lazy-init if needed
|
|
129
|
+
registered_names: set = set()
|
|
130
|
+
|
|
131
|
+
for agent_path_str in agent_files:
|
|
132
|
+
agent_path = Path(agent_path_str)
|
|
133
|
+
|
|
134
|
+
# Skip the main agent — it's handled separately
|
|
135
|
+
if agent_path.name == "main.agent.md":
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
agent = _load_agent_file(agent_path)
|
|
139
|
+
if not agent:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
metadata = agent["metadata"]
|
|
143
|
+
content = agent["content"]
|
|
144
|
+
trigger_spec = metadata.get("trigger")
|
|
145
|
+
|
|
146
|
+
if not isinstance(trigger_spec, dict) or "type" not in trigger_spec:
|
|
147
|
+
logging.warning(f"Skipping {agent_path.name}: missing or invalid 'trigger' section (must have 'type')")
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
# Extract trigger type and params
|
|
151
|
+
trigger_type = str(trigger_spec["type"]).strip()
|
|
152
|
+
trigger_params = {k: v for k, v in trigger_spec.items() if k != "type"}
|
|
153
|
+
|
|
154
|
+
# Resolve env vars on string params
|
|
155
|
+
trigger_params = _resolve_trigger_params(trigger_params)
|
|
156
|
+
|
|
157
|
+
# Agent-level settings
|
|
158
|
+
agent_name = metadata.get("name", agent_path.stem)
|
|
159
|
+
should_log = _to_bool(metadata.get("logger", True), default=True)
|
|
160
|
+
|
|
161
|
+
# Function name from filename
|
|
162
|
+
base_name = _safe_function_name(agent_path.stem)
|
|
163
|
+
function_name = base_name
|
|
164
|
+
suffix = 2
|
|
165
|
+
while function_name in registered_names:
|
|
166
|
+
function_name = f"{base_name}_{suffix}"
|
|
167
|
+
suffix += 1
|
|
168
|
+
registered_names.add(function_name)
|
|
169
|
+
|
|
170
|
+
# Per-agent connector tools (additive, deduplicated globally)
|
|
171
|
+
agent_connections = metadata.get("tools_from_connections")
|
|
172
|
+
if isinstance(agent_connections, list):
|
|
173
|
+
configure_connector_tools(agent_connections)
|
|
174
|
+
|
|
175
|
+
# Per-agent sandbox tools
|
|
176
|
+
agent_sandbox_tools = []
|
|
177
|
+
agent_sandbox = metadata.get("execution_sandbox")
|
|
178
|
+
if isinstance(agent_sandbox, dict):
|
|
179
|
+
agent_sandbox_tools = create_sandbox_tools(agent_sandbox)
|
|
180
|
+
|
|
181
|
+
# Determine if this is a built-in trigger or connector trigger
|
|
182
|
+
# Dot notation routes to the connectors library (e.g. "teams.new_channel_message_trigger").
|
|
183
|
+
# "connectors." prefix is stripped if present (e.g. "connectors.generic_trigger" → "generic_trigger").
|
|
184
|
+
is_connector = "." in trigger_type
|
|
185
|
+
if is_connector:
|
|
186
|
+
# Strip leading "connectors." prefix if present
|
|
187
|
+
connector_type = trigger_type.removeprefix("connectors.")
|
|
188
|
+
connectors_instance = _register_connector_agent(
|
|
189
|
+
app, connectors_instance, function_name, agent_name,
|
|
190
|
+
connector_type, trigger_params, content, should_log,
|
|
191
|
+
sandbox_tools=agent_sandbox_tools,
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
# Built-in Azure Functions trigger
|
|
195
|
+
_register_builtin_agent(
|
|
196
|
+
app, function_name, agent_name,
|
|
197
|
+
trigger_type, trigger_params, content, should_log,
|
|
198
|
+
sandbox_tools=agent_sandbox_tools,
|
|
199
|
+
response_example=metadata.get("response_example"),
|
|
200
|
+
response_schema=metadata.get("response_schema"),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _register_builtin_agent(
|
|
205
|
+
app: func.FunctionApp,
|
|
206
|
+
function_name: str,
|
|
207
|
+
agent_name: str,
|
|
208
|
+
trigger_type: str,
|
|
209
|
+
trigger_params: Dict[str, Any],
|
|
210
|
+
prompt: str,
|
|
211
|
+
should_log: bool,
|
|
212
|
+
sandbox_tools: Optional[list] = None,
|
|
213
|
+
response_example: Optional[str] = None,
|
|
214
|
+
response_schema: Optional[dict] = None,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Register a triggered agent using a built-in Azure Functions trigger."""
|
|
217
|
+
|
|
218
|
+
# HTTP triggers use a dedicated handler that returns func.HttpResponse
|
|
219
|
+
if trigger_type == "http_trigger":
|
|
220
|
+
_register_http_agent(
|
|
221
|
+
app, function_name, agent_name, trigger_params, prompt,
|
|
222
|
+
should_log, sandbox_tools=sandbox_tools,
|
|
223
|
+
response_example=response_example, response_schema=response_schema,
|
|
224
|
+
)
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
# Get the decorator method from the FunctionApp
|
|
228
|
+
decorator_fn = getattr(app, trigger_type, None)
|
|
229
|
+
if decorator_fn is None:
|
|
230
|
+
logging.warning(f"Skipping '{function_name}': unknown trigger type '{trigger_type}'")
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
# Timer triggers: normalize schedule
|
|
234
|
+
if trigger_type == "timer_trigger":
|
|
235
|
+
if "schedule" in trigger_params:
|
|
236
|
+
trigger_params["schedule"] = _normalize_timer_schedule(str(trigger_params["schedule"]))
|
|
237
|
+
|
|
238
|
+
# Create handler
|
|
239
|
+
handler = _make_agent_handler(function_name, agent_name, trigger_type, should_log, sandbox_tools=sandbox_tools, agent_instructions=prompt)
|
|
240
|
+
|
|
241
|
+
# Register with auto-generated arg_name
|
|
242
|
+
trigger_params["arg_name"] = "trigger_data"
|
|
243
|
+
try:
|
|
244
|
+
decorated = decorator_fn(**trigger_params)(handler)
|
|
245
|
+
app.function_name(name=function_name)(decorated)
|
|
246
|
+
logging.info(f"Registered '{function_name}' ({trigger_type}) — {agent_name}")
|
|
247
|
+
except Exception as exc:
|
|
248
|
+
logging.error(f"Failed to register '{function_name}' ({trigger_type}): {exc}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
_AUTH_LEVEL_MAP = {
|
|
252
|
+
"anonymous": func.AuthLevel.ANONYMOUS,
|
|
253
|
+
"function": func.AuthLevel.FUNCTION,
|
|
254
|
+
"admin": func.AuthLevel.ADMIN,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _register_http_agent(
|
|
259
|
+
app: func.FunctionApp,
|
|
260
|
+
function_name: str,
|
|
261
|
+
agent_name: str,
|
|
262
|
+
trigger_params: Dict[str, Any],
|
|
263
|
+
prompt: str,
|
|
264
|
+
should_log: bool,
|
|
265
|
+
sandbox_tools: Optional[list] = None,
|
|
266
|
+
response_example: Optional[str] = None,
|
|
267
|
+
response_schema: Optional[dict] = None,
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Register an HTTP-triggered agent using app.route()."""
|
|
270
|
+
route = trigger_params.get("route")
|
|
271
|
+
if not route:
|
|
272
|
+
logging.warning(f"Skipping '{function_name}': http_trigger requires 'route'")
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
methods = trigger_params.get("methods", ["POST"])
|
|
276
|
+
auth_str = str(trigger_params.get("auth_level", "FUNCTION")).lower()
|
|
277
|
+
auth_level = _AUTH_LEVEL_MAP.get(auth_str, func.AuthLevel.FUNCTION)
|
|
278
|
+
|
|
279
|
+
handler = _make_http_agent_handler(
|
|
280
|
+
function_name, agent_name, should_log,
|
|
281
|
+
sandbox_tools=sandbox_tools, agent_instructions=prompt,
|
|
282
|
+
response_example=response_example, response_schema=response_schema,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
decorated = app.route(route=route, methods=methods, auth_level=auth_level)(handler)
|
|
287
|
+
app.function_name(name=function_name)(decorated)
|
|
288
|
+
logging.info(f"Registered HTTP agent '{function_name}' at /{route} ({methods}) — {agent_name}")
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
logging.error(f"Failed to register HTTP agent '{function_name}': {exc}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _register_connector_agent(
|
|
294
|
+
app: func.FunctionApp,
|
|
295
|
+
connectors_instance,
|
|
296
|
+
function_name: str,
|
|
297
|
+
agent_name: str,
|
|
298
|
+
trigger_type: str,
|
|
299
|
+
trigger_params: Dict[str, Any],
|
|
300
|
+
prompt: str,
|
|
301
|
+
should_log: bool,
|
|
302
|
+
sandbox_tools: Optional[list] = None,
|
|
303
|
+
):
|
|
304
|
+
"""Register a triggered agent using a connector trigger.
|
|
305
|
+
|
|
306
|
+
Returns the connectors instance (created lazily on first use).
|
|
307
|
+
"""
|
|
308
|
+
if connectors_instance is None:
|
|
309
|
+
try:
|
|
310
|
+
import azure.functions_connectors as fc
|
|
311
|
+
connectors_instance = fc.FunctionsConnectors(app)
|
|
312
|
+
except ImportError:
|
|
313
|
+
logging.error(
|
|
314
|
+
f"Skipping '{function_name}': azure-functions-connectors package not installed. "
|
|
315
|
+
"Install from: https://github.com/anthonychu/azure-functions-connectors-python"
|
|
316
|
+
)
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
# Resolve the decorator via getattr chain (e.g. "teams.new_channel_message_trigger")
|
|
320
|
+
# For top-level methods like "generic_trigger", it's a single getattr
|
|
321
|
+
parts = trigger_type.split(".")
|
|
322
|
+
obj = connectors_instance
|
|
323
|
+
try:
|
|
324
|
+
for part in parts:
|
|
325
|
+
obj = getattr(obj, part)
|
|
326
|
+
decorator_fn = obj
|
|
327
|
+
except AttributeError:
|
|
328
|
+
logging.warning(f"Skipping '{function_name}': could not resolve connector trigger '{trigger_type}'")
|
|
329
|
+
return connectors_instance
|
|
330
|
+
|
|
331
|
+
handler = _make_agent_handler(function_name, agent_name, trigger_type, should_log, sandbox_tools=sandbox_tools, agent_instructions=prompt)
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
decorator_fn(**trigger_params)(handler)
|
|
335
|
+
logging.info(f"Registered '{function_name}' ({trigger_type}) — {agent_name}")
|
|
336
|
+
except Exception as exc:
|
|
337
|
+
logging.error(f"Failed to register '{function_name}' ({trigger_type}): {exc}")
|
|
338
|
+
|
|
339
|
+
return connectors_instance
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _serialize_trigger_data(trigger_data) -> str:
|
|
343
|
+
"""Serialize trigger binding data to a JSON string."""
|
|
344
|
+
if trigger_data is None:
|
|
345
|
+
return "{}"
|
|
346
|
+
if hasattr(trigger_data, "to_dict"):
|
|
347
|
+
payload = trigger_data.to_dict()
|
|
348
|
+
elif hasattr(trigger_data, "model_dump"):
|
|
349
|
+
payload = trigger_data.model_dump()
|
|
350
|
+
elif isinstance(trigger_data, dict):
|
|
351
|
+
payload = trigger_data
|
|
352
|
+
elif isinstance(trigger_data, str):
|
|
353
|
+
return trigger_data
|
|
354
|
+
else:
|
|
355
|
+
payload = str(trigger_data)
|
|
356
|
+
|
|
357
|
+
if isinstance(payload, dict):
|
|
358
|
+
return json.dumps(payload, ensure_ascii=False, default=str)
|
|
359
|
+
return str(payload)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _make_agent_handler(
|
|
363
|
+
function_name: str,
|
|
364
|
+
agent_name: str,
|
|
365
|
+
trigger_type: str,
|
|
366
|
+
should_log: bool,
|
|
367
|
+
sandbox_tools: Optional[list] = None,
|
|
368
|
+
agent_instructions: Optional[str] = None,
|
|
369
|
+
):
|
|
370
|
+
"""Create an async handler function for a triggered agent."""
|
|
371
|
+
async def _handler(trigger_data):
|
|
372
|
+
logging.info(f"Agent '{function_name}' triggered")
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
data_json = _serialize_trigger_data(trigger_data)
|
|
376
|
+
parts = []
|
|
377
|
+
if agent_instructions:
|
|
378
|
+
parts.append(agent_instructions)
|
|
379
|
+
parts.append(f"Triggered by: {trigger_type}\n\nTrigger data:\n```json\n{data_json}\n```")
|
|
380
|
+
prompt = "\n\n".join(parts)
|
|
381
|
+
|
|
382
|
+
result = await run_copilot_agent(prompt, sandbox_tools=sandbox_tools)
|
|
383
|
+
|
|
384
|
+
if should_log:
|
|
385
|
+
logging.info(
|
|
386
|
+
"Agent '%s' response: %s",
|
|
387
|
+
function_name,
|
|
388
|
+
json.dumps(
|
|
389
|
+
{
|
|
390
|
+
"session_id": result.session_id,
|
|
391
|
+
"response": result.content,
|
|
392
|
+
"response_intermediate": result.content_intermediate,
|
|
393
|
+
"tool_calls": result.tool_calls,
|
|
394
|
+
},
|
|
395
|
+
ensure_ascii=False,
|
|
396
|
+
default=str,
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
except Exception as exc:
|
|
400
|
+
logging.exception(f"Agent '{function_name}' failed: {exc}")
|
|
401
|
+
|
|
402
|
+
_handler.__name__ = f"handler_{function_name}"
|
|
403
|
+
return _handler
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _extract_json_from_response(text: str) -> str:
|
|
407
|
+
"""Extract JSON from an agent response, stripping markdown code fences if present."""
|
|
408
|
+
stripped = text.strip()
|
|
409
|
+
# Try to extract from ```json ... ``` or ``` ... ```
|
|
410
|
+
fence_match = re.search(r"```(?:json)?\s*\n(.*?)```", stripped, re.DOTALL)
|
|
411
|
+
if fence_match:
|
|
412
|
+
return fence_match.group(1).strip()
|
|
413
|
+
return stripped
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _make_http_agent_handler(
|
|
417
|
+
function_name: str,
|
|
418
|
+
agent_name: str,
|
|
419
|
+
should_log: bool,
|
|
420
|
+
sandbox_tools: Optional[list] = None,
|
|
421
|
+
agent_instructions: Optional[str] = None,
|
|
422
|
+
response_example: Optional[str] = None,
|
|
423
|
+
response_schema: Optional[dict] = None,
|
|
424
|
+
):
|
|
425
|
+
"""Create an async handler for an HTTP-triggered agent that returns structured JSON."""
|
|
426
|
+
async def _handler(req: Request) -> Response:
|
|
427
|
+
logging.info(f"HTTP agent '{function_name}' triggered")
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
# Parse request body
|
|
431
|
+
try:
|
|
432
|
+
body = await req.json()
|
|
433
|
+
body_json = json.dumps(body, ensure_ascii=False, default=str)
|
|
434
|
+
except Exception:
|
|
435
|
+
body_bytes = await req.body()
|
|
436
|
+
body_json = body_bytes.decode("utf-8", errors="replace") if body_bytes else "{}"
|
|
437
|
+
|
|
438
|
+
# Build prompt
|
|
439
|
+
parts = []
|
|
440
|
+
if agent_instructions:
|
|
441
|
+
parts.append(agent_instructions)
|
|
442
|
+
|
|
443
|
+
# Add response format instructions
|
|
444
|
+
if response_example:
|
|
445
|
+
parts.append(
|
|
446
|
+
"You MUST respond with ONLY a valid JSON object (no markdown, no explanation, no code fences). "
|
|
447
|
+
f"Your response must match this example format:\n```json\n{response_example}\n```"
|
|
448
|
+
)
|
|
449
|
+
elif response_schema:
|
|
450
|
+
schema_str = json.dumps(response_schema, indent=2)
|
|
451
|
+
parts.append(
|
|
452
|
+
"You MUST respond with ONLY a valid JSON object (no markdown, no explanation, no code fences). "
|
|
453
|
+
f"Your response must conform to this JSON Schema:\n```json\n{schema_str}\n```"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
parts.append(f"HTTP request data:\n```json\n{body_json}\n```")
|
|
457
|
+
prompt = "\n\n".join(parts)
|
|
458
|
+
|
|
459
|
+
result = await run_copilot_agent(prompt, sandbox_tools=sandbox_tools)
|
|
460
|
+
|
|
461
|
+
if should_log:
|
|
462
|
+
logging.info(
|
|
463
|
+
"HTTP agent '%s' response: %s",
|
|
464
|
+
function_name,
|
|
465
|
+
json.dumps(
|
|
466
|
+
{"session_id": result.session_id, "response": result.content[:500]},
|
|
467
|
+
ensure_ascii=False, default=str,
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# If a response format was specified, parse as JSON
|
|
472
|
+
if response_example or response_schema:
|
|
473
|
+
extracted = _extract_json_from_response(result.content)
|
|
474
|
+
try:
|
|
475
|
+
parsed = json.loads(extracted)
|
|
476
|
+
return Response(
|
|
477
|
+
content=json.dumps(parsed, ensure_ascii=False),
|
|
478
|
+
status_code=200,
|
|
479
|
+
media_type="application/json",
|
|
480
|
+
)
|
|
481
|
+
except json.JSONDecodeError as je:
|
|
482
|
+
logging.warning(f"HTTP agent '{function_name}' returned invalid JSON: {je}")
|
|
483
|
+
return Response(
|
|
484
|
+
content=json.dumps({"error": "Agent returned invalid JSON", "raw_response": result.content}),
|
|
485
|
+
status_code=500,
|
|
486
|
+
media_type="application/json",
|
|
487
|
+
)
|
|
488
|
+
else:
|
|
489
|
+
# No schema — return raw text
|
|
490
|
+
return Response(
|
|
491
|
+
content=result.content,
|
|
492
|
+
status_code=200,
|
|
493
|
+
media_type="text/plain",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
except Exception as exc:
|
|
497
|
+
logging.exception(f"HTTP agent '{function_name}' failed: {exc}")
|
|
498
|
+
return Response(
|
|
499
|
+
content=json.dumps({"error": str(exc)}),
|
|
500
|
+
status_code=500,
|
|
501
|
+
media_type="application/json",
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
_handler.__name__ = f"handler_{function_name}"
|
|
505
|
+
return _handler
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ---------------------------------------------------------------------------
|
|
509
|
+
# App factory
|
|
510
|
+
# ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
def create_function_app(app_root: Path | None = None) -> func.FunctionApp:
|
|
513
|
+
"""Build and return a fully-configured Azure Functions app.
|
|
514
|
+
|
|
515
|
+
Parameters
|
|
516
|
+
----------
|
|
517
|
+
app_root:
|
|
518
|
+
Root directory of the agent project (contains ``main.agent.md``,
|
|
519
|
+
``tools/``, ``skills/``, etc.). When *None*, falls back to
|
|
520
|
+
``COPILOT_APP_ROOT`` env var or the current working directory.
|
|
521
|
+
"""
|
|
522
|
+
if app_root is not None:
|
|
523
|
+
set_app_root(app_root)
|
|
524
|
+
|
|
525
|
+
resolved_root = get_app_root()
|
|
526
|
+
|
|
527
|
+
app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION)
|
|
528
|
+
|
|
529
|
+
# ---- Load main agent (main.agent.md) ----
|
|
530
|
+
main_agent = _load_agent_file(resolved_root / "main.agent.md")
|
|
531
|
+
|
|
532
|
+
# ---- Register triggered agents from *.agent.md ----
|
|
533
|
+
_register_triggered_agents(app, resolved_root)
|
|
534
|
+
|
|
535
|
+
# ---- Configure main agent (if present) ----
|
|
536
|
+
metadata: Dict[str, Any] = {}
|
|
537
|
+
main_sandbox_tools: list = []
|
|
538
|
+
mcp_tool_name = "agent_chat"
|
|
539
|
+
mcp_tool_description = "Run an agent chat turn with a prompt."
|
|
540
|
+
|
|
541
|
+
if main_agent:
|
|
542
|
+
metadata = main_agent["metadata"]
|
|
543
|
+
|
|
544
|
+
mcp_tool_name = _safe_mcp_tool_name(
|
|
545
|
+
str(metadata.get("name") or "agent_chat")
|
|
546
|
+
)
|
|
547
|
+
mcp_tool_description = str(
|
|
548
|
+
metadata.get("description") or "Run an agent chat turn with a prompt."
|
|
549
|
+
).strip() or "Run an agent chat turn with a prompt."
|
|
550
|
+
|
|
551
|
+
# ---- Configure connector tools from main agent frontmatter ----
|
|
552
|
+
tools_from_connections = metadata.get("tools_from_connections")
|
|
553
|
+
if isinstance(tools_from_connections, list):
|
|
554
|
+
configure_connector_tools(tools_from_connections)
|
|
555
|
+
|
|
556
|
+
# ---- Configure execution sandbox from main agent frontmatter ----
|
|
557
|
+
execution_sandbox = metadata.get("execution_sandbox")
|
|
558
|
+
if isinstance(execution_sandbox, dict):
|
|
559
|
+
main_sandbox_tools = create_sandbox_tools(execution_sandbox)
|
|
560
|
+
else:
|
|
561
|
+
logging.info("No main.agent.md found — HTTP chat, MCP, and UI endpoints will return 404.")
|
|
562
|
+
|
|
563
|
+
# ---- HTTP routes (always registered) ----
|
|
564
|
+
|
|
565
|
+
@app.route(
|
|
566
|
+
route="{*ignored}",
|
|
567
|
+
methods=["GET"],
|
|
568
|
+
auth_level=func.AuthLevel.ANONYMOUS,
|
|
569
|
+
)
|
|
570
|
+
def root_chat_page(req: Request) -> Response:
|
|
571
|
+
"""Serve the chat UI at the root route."""
|
|
572
|
+
ignored = (req.path_params or {}).get("ignored", "")
|
|
573
|
+
if ignored:
|
|
574
|
+
return Response("Not found", status_code=404)
|
|
575
|
+
|
|
576
|
+
if not main_agent:
|
|
577
|
+
return Response("Not found", status_code=404)
|
|
578
|
+
|
|
579
|
+
index_path = Path(__file__).parent / "public" / "index.html"
|
|
580
|
+
if not index_path.exists():
|
|
581
|
+
return Response("index.html not found", status_code=404)
|
|
582
|
+
|
|
583
|
+
return Response(
|
|
584
|
+
index_path.read_text(encoding="utf-8"),
|
|
585
|
+
status_code=200,
|
|
586
|
+
media_type="text/html",
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
@app.route(route="agent/chat", methods=["POST"])
|
|
590
|
+
async def chat(req: Request) -> Response:
|
|
591
|
+
"""
|
|
592
|
+
Chat endpoint - send a prompt, get a response.
|
|
593
|
+
|
|
594
|
+
POST /agent/chat
|
|
595
|
+
Headers:
|
|
596
|
+
x-ms-session-id (optional): Session ID for resuming a previous session
|
|
597
|
+
Body:
|
|
598
|
+
{
|
|
599
|
+
"prompt": "What is 2+2?"
|
|
600
|
+
}
|
|
601
|
+
"""
|
|
602
|
+
try:
|
|
603
|
+
body = await req.json()
|
|
604
|
+
prompt = body.get("prompt")
|
|
605
|
+
|
|
606
|
+
if not prompt:
|
|
607
|
+
return Response(
|
|
608
|
+
json.dumps({"error": "Missing 'prompt'"}),
|
|
609
|
+
status_code=400,
|
|
610
|
+
media_type="application/json",
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
session_id = req.headers.get("x-ms-session-id")
|
|
614
|
+
result = await run_copilot_agent(prompt, session_id=session_id, sandbox_tools=main_sandbox_tools)
|
|
615
|
+
|
|
616
|
+
response = Response(
|
|
617
|
+
json.dumps(
|
|
618
|
+
{
|
|
619
|
+
"session_id": result.session_id,
|
|
620
|
+
"response": result.content,
|
|
621
|
+
"response_intermediate": result.content_intermediate,
|
|
622
|
+
"tool_calls": result.tool_calls,
|
|
623
|
+
}
|
|
624
|
+
),
|
|
625
|
+
media_type="application/json",
|
|
626
|
+
headers={"x-ms-session-id": result.session_id},
|
|
627
|
+
)
|
|
628
|
+
return response
|
|
629
|
+
|
|
630
|
+
except Exception as e:
|
|
631
|
+
error_msg = str(e) if str(e) else f"{type(e).__name__}: {repr(e)}"
|
|
632
|
+
logging.error(f"Chat error: {error_msg}")
|
|
633
|
+
return Response(
|
|
634
|
+
json.dumps({"error": error_msg}), status_code=500, media_type="application/json"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
@app.route(route="agent/chatstream", methods=["POST"])
|
|
638
|
+
async def chat_stream(req: Request) -> StreamingResponse:
|
|
639
|
+
"""
|
|
640
|
+
Streaming chat endpoint - send a prompt, receive SSE events.
|
|
641
|
+
|
|
642
|
+
POST /agent/chat/stream
|
|
643
|
+
Headers:
|
|
644
|
+
x-ms-session-id (optional): Session ID for resuming a previous session
|
|
645
|
+
Body:
|
|
646
|
+
{
|
|
647
|
+
"prompt": "What is 2+2?"
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
Response: text/event-stream with events:
|
|
651
|
+
data: {"type": "session", "session_id": "..."}
|
|
652
|
+
data: {"type": "delta", "content": "partial text"}
|
|
653
|
+
data: {"type": "tool_start", "tool_name": "...", "tool_call_id": "..."}
|
|
654
|
+
data: {"type": "message", "content": "full message"}
|
|
655
|
+
data: {"type": "done"}
|
|
656
|
+
"""
|
|
657
|
+
try:
|
|
658
|
+
body = await req.json()
|
|
659
|
+
prompt = body.get("prompt")
|
|
660
|
+
|
|
661
|
+
if not main_agent:
|
|
662
|
+
async def no_agent_gen():
|
|
663
|
+
yield f"data: {json.dumps({'type': 'error', 'content': 'No main.agent.md found. Create a main.agent.md file in the app root to enable this endpoint.'})}\n\n"
|
|
664
|
+
return StreamingResponse(no_agent_gen(), media_type="text/event-stream", status_code=404)
|
|
665
|
+
|
|
666
|
+
if not prompt:
|
|
667
|
+
async def error_gen():
|
|
668
|
+
yield f"data: {json.dumps({'type': 'error', 'content': 'Missing prompt'})}\n\n"
|
|
669
|
+
return StreamingResponse(error_gen(), media_type="text/event-stream")
|
|
670
|
+
|
|
671
|
+
session_id = req.headers.get("x-ms-session-id")
|
|
672
|
+
return StreamingResponse(
|
|
673
|
+
run_copilot_agent_stream(prompt, session_id=session_id, sandbox_tools=main_sandbox_tools),
|
|
674
|
+
media_type="text/event-stream",
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
except Exception as e:
|
|
678
|
+
error_msg = str(e) if str(e) else f"{type(e).__name__}: {repr(e)}"
|
|
679
|
+
logging.error(f"Chat stream error: {error_msg}")
|
|
680
|
+
async def error_gen():
|
|
681
|
+
yield f"data: {json.dumps({'type': 'error', 'content': error_msg})}\n\n"
|
|
682
|
+
return StreamingResponse(error_gen(), media_type="text/event-stream")
|
|
683
|
+
|
|
684
|
+
# ---- MCP tool (only when main agent exists) ----
|
|
685
|
+
|
|
686
|
+
if main_agent:
|
|
687
|
+
@app.mcp_tool_trigger(
|
|
688
|
+
arg_name="context",
|
|
689
|
+
tool_name=mcp_tool_name,
|
|
690
|
+
description=mcp_tool_description,
|
|
691
|
+
tool_properties=_MCP_AGENT_TOOL_PROPERTIES,
|
|
692
|
+
)
|
|
693
|
+
async def mcp_agent_chat(context: str) -> str:
|
|
694
|
+
"""MCP tool endpoint that runs the same agent workflow as /agent/chat."""
|
|
695
|
+
try:
|
|
696
|
+
payload = json.loads(context) if context else {}
|
|
697
|
+
arguments = payload.get("arguments", {}) if isinstance(payload, dict) else {}
|
|
698
|
+
|
|
699
|
+
prompt = arguments.get("prompt") if isinstance(arguments, dict) else None
|
|
700
|
+
if not isinstance(prompt, str) or not prompt.strip():
|
|
701
|
+
return json.dumps({"error": "Missing 'prompt'"})
|
|
702
|
+
|
|
703
|
+
session_id = _extract_mcp_session_id(payload) if isinstance(payload, dict) else None
|
|
704
|
+
|
|
705
|
+
result = await run_copilot_agent(prompt.strip(), session_id=session_id, sandbox_tools=main_sandbox_tools)
|
|
706
|
+
|
|
707
|
+
return json.dumps(
|
|
708
|
+
{
|
|
709
|
+
"session_id": result.session_id,
|
|
710
|
+
"response": result.content,
|
|
711
|
+
"response_intermediate": result.content_intermediate,
|
|
712
|
+
"tool_calls": result.tool_calls,
|
|
713
|
+
}
|
|
714
|
+
)
|
|
715
|
+
except Exception as exc:
|
|
716
|
+
error_msg = str(exc) if str(exc) else f"{type(exc).__name__}: {repr(exc)}"
|
|
717
|
+
logging.error(f"MCP tool error: {error_msg}")
|
|
718
|
+
return json.dumps({"error": error_msg})
|
|
719
|
+
|
|
720
|
+
return app
|