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,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