Skip to content

Commit c52013a

Browse files
authored
Pypi upgrade for python autopilot sample, email support (#569)
* just to commit. * add word comment notifications. * Just to commit. * middleware cleanup * cleanup.
1 parent 5ec7504 commit c52013a

8 files changed

Lines changed: 403 additions & 187 deletions

File tree

samples/python/foundry-autopilot-agent/infra/main.bicep

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ param cognitiveServicesSku string = 'S0'
3333
@allowed(['Basic', 'Standard', 'Premium'])
3434
param containerRegistrySku string = 'Basic'
3535

36-
param agentName string = 'foundry-agent'
36+
param agentName string = 'foundry-autopilot-agent'
3737

3838
param maibName string = '${agentName}-maib'
3939

@@ -51,10 +51,10 @@ param botDisplayName string = '${agentName} Bot'
5151
param botServiceSku string = 'F0'
5252

5353
@description('Model name')
54-
param modelName string = 'gpt-5.3-chat'
54+
param modelName string = 'gpt-chat-latest'
5555

5656
@description('Model version')
57-
param modelVersion string = '2026-03-03'
57+
param modelVersion string = '2026-05-28'
5858

5959
// =================================================================================================
6060
// Common parameters

samples/python/foundry-autopilot-agent/src/hello_world_a365_agent/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ AZURE_OPENAI_API_KEY=
2323
AZURE_OPENAI_API_VERSION=2025-03-01-preview
2424

2525
# ── Microsoft 365 Agents SDK service connection (the Connections section in appsettings.json) ──
26-
CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHTYPE=UserManagedIdentity
26+
CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHTYPE=IdentityProxyManager
2727
CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=
2828
CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHORITY=https://login.microsoftonline.com/<TENANT_ID>
2929
CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=<TENANT_ID>

samples/python/foundry-autopilot-agent/src/hello_world_a365_agent/agent.py

Lines changed: 241 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
NotificationTypes = None # type: ignore[assignment]
3939

4040
from .agent_interface import AgentInterface
41+
from .email_channel_compat import is_email_notification
4142
from .token_cache import get_cached_agentic_token
4243

4344
logger = logging.getLogger(__name__)
@@ -288,26 +289,21 @@ async def handle_agent_notification_activity(
288289
getattr(conversation, "id", "") or f"notification:{notification_type}"
289290
)
290291

291-
is_email = (
292-
NotificationTypes is not None
293-
and notification_type == NotificationTypes.EMAIL_NOTIFICATION
294-
)
295-
is_wpx_comment = (
296-
NotificationTypes is not None
297-
and notification_type == NotificationTypes.WPX_COMMENT
298-
)
292+
is_email = is_email_notification(notification_activity)
293+
is_wpx_comment = self._is_wpx_comment_notification(notification_type)
299294

300295
if is_email:
301-
email = getattr(notification_activity, "email", None)
302-
email_body = (
303-
getattr(email, "html_body", "") or getattr(email, "body", "")
304-
if email
305-
else ""
296+
from_prop = getattr(
297+
notification_activity, "from_property", None
298+
) or getattr(notification_activity, "from", None)
299+
from_email = (
300+
getattr(from_prop, "id", "") or getattr(from_prop, "name", "")
306301
)
302+
email_details = self._serialize_notification(notification_activity)
307303
msg = (
308-
getattr(notification_activity, "text", "")
309-
or "You have received the following email. Please follow any "
310-
f"instructions in it. {email_body}"
304+
"You received a new email. Please look at the email and return "
305+
"a response in html format. "
306+
f"From: {from_email}\nEmail details:\n{email_details}"
311307
)
312308
return await self._invoke_responses_api(
313309
input_text=msg,
@@ -319,40 +315,12 @@ async def handle_agent_notification_activity(
319315
) or "Email notification processed."
320316

321317
if is_wpx_comment:
322-
wpx = getattr(notification_activity, "wpx_comment", None)
323-
doc_id = getattr(wpx, "document_id", "") if wpx else ""
324-
comment_id = getattr(wpx, "initiating_comment_id", "") if wpx else ""
325-
drive_id = "default"
326-
comment_text = getattr(notification_activity, "text", "") or ""
327-
328-
doc_message = (
329-
f"You have a new comment on the document with id '{doc_id}', "
330-
f"comment id '{comment_id}', drive id '{drive_id}'. Please "
331-
"retrieve the document as well as the comments and return it "
332-
"in text format."
333-
)
334-
doc_content = await self._invoke_responses_api(
335-
input_text=doc_message,
336-
conversation_id=conversation_id,
337-
instructions=self.AGENT_PROMPT,
338-
auth=auth,
339-
auth_handler_name=auth_handler_name,
340-
context=context,
341-
)
342-
343-
response_message = (
344-
"You have received the following document content and "
345-
"comments. Please refer to these when responding to "
346-
f"comment '{comment_text}'. {doc_content}"
318+
return await self._handle_comment_notification(
319+
notification_activity,
320+
auth,
321+
auth_handler_name,
322+
context,
347323
)
348-
return await self._invoke_responses_api(
349-
input_text=response_message,
350-
conversation_id=conversation_id,
351-
instructions=self.AGENT_PROMPT,
352-
auth=auth,
353-
auth_handler_name=auth_handler_name,
354-
context=context,
355-
) or "Comment notification processed."
356324

357325
notification_message = (
358326
getattr(notification_activity, "text", "")
@@ -370,6 +338,230 @@ async def handle_agent_notification_activity(
370338
logger.exception("Error processing notification")
371339
return f"Sorry, I encountered an error processing the notification: {ex}"
372340

341+
async def _handle_comment_notification(
342+
self,
343+
notification_activity: Any,
344+
auth: Authorization,
345+
auth_handler_name: Optional[str],
346+
context: TurnContext,
347+
) -> str:
348+
logger.info("Processing comment notification (Responses API)")
349+
350+
comment = self._get_comment_payload(notification_activity)
351+
if comment is None:
352+
logger.warning("Comment notification received without WpxComment payload")
353+
return ""
354+
355+
document_id = self._get_first_value(comment, "document_id", "documentId")
356+
comment_id = self._get_first_value(
357+
comment,
358+
"comment_id",
359+
"commentId",
360+
"initiating_comment_id",
361+
"initiatingCommentId",
362+
)
363+
parent_comment_id = self._get_first_value(
364+
comment,
365+
"parent_comment_id",
366+
"parentCommentId",
367+
)
368+
369+
content_url = self._get_first_attachment_content_url(context)
370+
if not content_url:
371+
logger.warning(
372+
"Comment notification for CommentId=%s on DocumentId=%s has no attachment ContentUrl",
373+
comment_id,
374+
document_id,
375+
)
376+
return ""
377+
378+
product_label, mcp_server_name = self._infer_product_from_activity(
379+
context.activity,
380+
content_url,
381+
)
382+
from_prop = getattr(
383+
notification_activity, "from_property", None
384+
) or getattr(notification_activity, "from", None)
385+
commenter = (
386+
getattr(from_prop, "name", "")
387+
or getattr(from_prop, "id", "")
388+
or "the commenter"
389+
)
390+
comment_text = (
391+
getattr(context.activity, "text", "")
392+
or getattr(notification_activity, "text", "")
393+
or ""
394+
).strip()
395+
comment_snippet = comment_text or "(no comment text)"
396+
document_id_for_prompt = document_id or "unknown-doc"
397+
comment_id_for_prompt = comment_id or "unknown-comment"
398+
parent_comment = parent_comment_id or "(none - this is a top-level comment)"
399+
conversation_id = f"comment:{document_id_for_prompt}:{comment_id_for_prompt}"
400+
401+
prompt = f"""
402+
You have been @-mentioned in a {product_label} comment and must reply to it.
403+
404+
Use the {mcp_server_name} MCP tools to do the following, in order:
405+
1. Call GetDocumentContent with the sharing URL below to read the document and
406+
locate the text that the comment refers to.
407+
2. Call ReplyToComment with commentId="{comment_id_for_prompt}" to post your
408+
reply directly on the thread. Do NOT respond via chat or email - the reply
409+
must be posted through the {mcp_server_name} ReplyToComment tool so it
410+
shows up on the comment thread in the document.
411+
412+
Keep the reply concise, helpful, and grounded in the actual document content.
413+
Format the reply as plain text because the comment thread does not render HTML.
414+
415+
Document URL: {content_url}
416+
DocumentId: {document_id_for_prompt}
417+
CommentId: {comment_id_for_prompt}
418+
ParentCommentId: {parent_comment}
419+
Commenter: {commenter}
420+
Comment text: {comment_snippet}
421+
""".strip()
422+
423+
response = await self._invoke_responses_api(
424+
input_text=prompt,
425+
conversation_id=conversation_id,
426+
instructions=self.AGENT_PROMPT,
427+
auth=auth,
428+
auth_handler_name=auth_handler_name,
429+
context=context,
430+
)
431+
432+
logger.info(
433+
"Comment reply flow finished for %s CommentId=%s. Model output (for diagnostics only): %s",
434+
product_label,
435+
comment_id_for_prompt,
436+
response.strip() if response and response.strip() else "(empty)",
437+
)
438+
return ""
439+
440+
@staticmethod
441+
def _is_wpx_comment_notification(notification_type: Any) -> bool:
442+
if (
443+
NotificationTypes is not None
444+
and notification_type == NotificationTypes.WPX_COMMENT
445+
):
446+
return True
447+
448+
value = getattr(notification_type, "value", notification_type)
449+
normalized = str(value or "").lower()
450+
return "comment" in normalized and (
451+
"wpx" in normalized
452+
or "word" in normalized
453+
or "excel" in normalized
454+
or "powerpoint" in normalized
455+
)
456+
457+
@staticmethod
458+
def _get_comment_payload(notification_activity: Any) -> Any:
459+
for name in (
460+
"wpx_comment_notification",
461+
"wpx_comment",
462+
"wpxCommentNotification",
463+
"wpxComment",
464+
):
465+
value = FoundryDigitalWorkerAgent._get_first_value(
466+
notification_activity,
467+
name,
468+
)
469+
if value:
470+
return value
471+
return None
472+
473+
@staticmethod
474+
def _get_first_attachment_content_url(context: TurnContext) -> str:
475+
activity = getattr(context, "activity", None)
476+
attachments = getattr(activity, "attachments", None) or []
477+
for attachment in attachments:
478+
content_url = FoundryDigitalWorkerAgent._get_first_value(
479+
attachment,
480+
"content_url",
481+
"contentUrl",
482+
)
483+
if content_url:
484+
return content_url
485+
return ""
486+
487+
@staticmethod
488+
def _infer_product_from_activity(
489+
activity: Any,
490+
content_url: str,
491+
) -> tuple[str, str]:
492+
sub_channel = ""
493+
channel_id = getattr(activity, "channel_id", None)
494+
if channel_id is not None:
495+
sub_channel = str(getattr(channel_id, "sub_channel", "") or "")
496+
if not sub_channel:
497+
_, _, sub_channel = str(channel_id).partition(":")
498+
499+
normalized = sub_channel.lower()
500+
if "word" in normalized:
501+
return "Word", "mcp_WordServer"
502+
if "excel" in normalized:
503+
return "Excel", "mcp_ExcelServer"
504+
if "powerpoint" in normalized or "ppt" in normalized:
505+
return "PowerPoint", "mcp_PowerPointServer"
506+
507+
return FoundryDigitalWorkerAgent._infer_product_from_url(content_url)
508+
509+
@staticmethod
510+
def _infer_product_from_url(url: str) -> tuple[str, str]:
511+
lower = str(url).lower()
512+
if ".xlsx" in lower or ".xlsm" in lower or ".xlsb" in lower:
513+
return "Excel", "mcp_ExcelServer"
514+
if ".pptx" in lower or ".ppt" in lower:
515+
return "PowerPoint", "mcp_PowerPointServer"
516+
return "Word", "mcp_WordServer"
517+
518+
@staticmethod
519+
def _get_first_value(value: Any, *names: str) -> Any:
520+
if value is None:
521+
return ""
522+
if isinstance(value, dict):
523+
for name in names:
524+
item = value.get(name)
525+
if item is not None and item != "":
526+
return item
527+
return ""
528+
529+
for name in names:
530+
item = getattr(value, name, None)
531+
if item is not None and item != "":
532+
return item
533+
return ""
534+
535+
@staticmethod
536+
def _serialize_notification(notification_activity: Any) -> str:
537+
try:
538+
dump_json = getattr(notification_activity, "model_dump_json", None)
539+
if callable(dump_json):
540+
return dump_json(indent=2)
541+
except Exception as ex:
542+
logger.warning(
543+
"Failed to serialize notification via model_dump_json: %s", ex
544+
)
545+
546+
try:
547+
return json.dumps(
548+
notification_activity,
549+
default=FoundryDigitalWorkerAgent._json_default,
550+
indent=2,
551+
)
552+
except Exception as ex:
553+
logger.warning("Failed to serialize notification via json.dumps: %s", ex)
554+
return str(notification_activity)
555+
556+
@staticmethod
557+
def _json_default(value: Any) -> Any:
558+
model_dump = getattr(value, "model_dump", None)
559+
if callable(model_dump):
560+
return model_dump(mode="json", by_alias=True, exclude_none=True)
561+
if hasattr(value, "__dict__"):
562+
return value.__dict__
563+
return str(value)
564+
373565
# ------------------------------------------------------------------
374566
# Azure OpenAI Responses API
375567
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)