Skip to content

Commit 6435904

Browse files
committed
feat(a365): add front-end plugin and refactor telemetry/tooling modules
Major Features: - Add complete A365 front-end plugin implementation: * A365FrontEndPlugin: Main plugin integrating NAT workflows with Microsoft Agent 365 * A365FrontEndPluginWorker: Worker pattern implementation (462 lines) - Creates AgentApplication with CloudAdapter, MemoryStorage, MsalConnectionManager - Sets up message handlers for Teams chat - Implements notification handlers for Email, Word, Excel, PowerPoint, lifecycle events - Supports separate workflow routing for notifications via notification_workflow config - Handles error handling and cleanup * A365FrontEndConfig: Configuration with Entra ID auth, log_level, notification settings * Supports custom worker classes via runner_class config option - Add comprehensive test suite (2000+ lines): * Front-end integration tests (542 lines) * Front-end registration tests (233 lines) * Telemetry exporter integration tests (294 lines) * Telemetry registration tests (123 lines) * Tooling auth integration tests (613 lines) * Tooling registration tests (204 lines) Refactoring & Improvements: - Add shared exceptions module (A365Error, A365AuthenticationError, A365ConfigurationError, A365SDKError, A365WorkflowExecutionError) for consistent error handling - Refactor tooling module: * Extract tooling config to separate tooling_config.py file * Update type hints to Python 3.10+ syntax (| instead of Union/Optional) * Optimize tool_overrides conversion (move outside loop for efficiency) * Improve error handling with A365ConfigurationError for validation failures * Add defensive checks for server.mcp_server_name (handle None/empty) - Enhance telemetry module: * Add AuthenticationRef support with proactive token refresh * Remove string import path option for token_resolver (use AuthenticationRef only) * Improve token resolver implementation with async/sync bridge - Remove unused notifications module (functionality implemented directly in worker) - Update docstrings and error messages throughout - Update dependencies in pyproject.toml and uv.lock This commit adds a production-ready A365 front-end plugin with comprehensive test coverage and improves code quality across telemetry and tooling modules.
1 parent 2e68d93 commit 6435904

29 files changed

Lines changed: 3643 additions & 387 deletions

packages/nvidia_nat_a365/pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,12 @@ dependencies = [
5454
# Keep package version constraints as open as possible to avoid conflicts with other packages. Always define a minimum
5555
# version when adding a new package. If unsure, default to using `~=` instead of `==`. Does not apply to nvidia-nat packages.
5656
# Keep sorted!!!
57+
"microsoft-agents-a365-notifications~=0.1.0",
5758
"microsoft-agents-a365-observability-core~=0.1.0",
5859
"microsoft-agents-a365-tooling~=0.1.0",
59-
"microsoft-agents-a365-notifications~=0.1.0",
60+
"microsoft-agents-activity~=0.7.0",
61+
"microsoft-agents-hosting-aiohttp~=0.7.0",
62+
"microsoft-agents-hosting-core~=0.7.0",
6063
"nvidia-nat-core == {version}",
6164
"nvidia-nat-opentelemetry == {version}",
6265
]
@@ -83,3 +86,6 @@ nvidia-nat-test = { path = "../nvidia_nat_test", editable = true }
8386
[project.entry-points.'nat.components']
8487
nat_a365 = "nat.plugins.a365.register"
8588
nat_a365_telemetry = "nat.plugins.a365.telemetry.register"
89+
90+
[project.entry-points.'nat.front_ends']
91+
nat_a365 = "nat.plugins.a365.front_end.register"

packages/nvidia_nat_a365/src/nat/plugins/a365/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,19 @@
1515
# limitations under the License.
1616

1717
"""Microsoft Agent 365 plugin for NeMo Agent Toolkit."""
18+
19+
from nat.plugins.a365.exceptions import (
20+
A365AuthenticationError,
21+
A365ConfigurationError,
22+
A365Error,
23+
A365SDKError,
24+
A365WorkflowExecutionError,
25+
)
26+
27+
__all__ = [
28+
"A365Error",
29+
"A365AuthenticationError",
30+
"A365ConfigurationError",
31+
"A365WorkflowExecutionError",
32+
"A365SDKError",
33+
]
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES.
2+
# All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
"""Custom exceptions for A365 plugin (shared across all modules)."""
18+
19+
20+
class A365Error(Exception):
21+
"""Base exception for A365 plugin errors."""
22+
pass
23+
24+
25+
class A365AuthenticationError(A365Error):
26+
"""Authentication-related errors.
27+
28+
Used for authentication failures across A365 modules:
29+
- Front-end: Bot Framework authentication failures
30+
- Tooling: A365 Gateway and MCP server authentication failures
31+
- Telemetry: Token resolver authentication failures
32+
"""
33+
34+
def __init__(self, message: str, original_error: Exception | None = None):
35+
super().__init__(message)
36+
self.original_error = original_error
37+
38+
39+
class A365ConfigurationError(A365Error):
40+
"""Configuration-related errors.
41+
42+
Used for configuration validation failures across A365 modules:
43+
- Front-end: Invalid front-end configuration (missing fields, wrong types)
44+
- Tooling: Invalid tooling configuration (reconnect settings, auth config)
45+
- Telemetry: Invalid telemetry configuration (token resolver path)
46+
"""
47+
48+
def __init__(self, message: str, original_error: Exception | None = None):
49+
super().__init__(message)
50+
self.original_error = original_error
51+
52+
53+
class A365WorkflowExecutionError(A365Error):
54+
"""Errors during workflow execution.
55+
56+
Used when NAT workflows fail during execution in A365 handlers.
57+
"""
58+
59+
def __init__(self, message: str, workflow_type: str = "workflow", original_error: Exception | None = None):
60+
super().__init__(message)
61+
self.workflow_type = workflow_type
62+
self.original_error = original_error
63+
64+
65+
class A365SDKError(A365Error):
66+
"""Errors related to Microsoft Agents SDK components.
67+
68+
Used for SDK-related errors across A365 modules:
69+
- Front-end: Microsoft Agents SDK (AgentApplication, CloudAdapter, etc.)
70+
- Telemetry: Agent365Exporter SDK errors
71+
- Tooling: McpToolServerConfigurationService SDK errors
72+
"""
73+
74+
def __init__(self, message: str, sdk_component: str | None = None, original_error: Exception | None = None):
75+
super().__init__(message)
76+
self.sdk_component = sdk_component
77+
self.original_error = original_error
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES.
2+
# All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
"""Microsoft Agent 365 front-end plugin."""
18+
19+
from nat.plugins.a365.exceptions import (
20+
A365AuthenticationError,
21+
A365ConfigurationError,
22+
A365Error,
23+
A365SDKError,
24+
A365WorkflowExecutionError,
25+
)
26+
27+
from .front_end_config import A365FrontEndConfig
28+
from .plugin import A365FrontEndPlugin
29+
from .worker import A365FrontEndPluginWorker
30+
31+
__all__ = [
32+
"A365FrontEndConfig",
33+
"A365FrontEndPlugin",
34+
"A365FrontEndPluginWorker",
35+
"A365Error",
36+
"A365AuthenticationError",
37+
"A365ConfigurationError",
38+
"A365WorkflowExecutionError",
39+
"A365SDKError",
40+
]
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES.
2+
# All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
"""Backward compatibility: Import exceptions from shared module."""
18+
19+
# Import from shared exceptions module
20+
from nat.plugins.a365.exceptions import (
21+
A365AuthenticationError,
22+
A365ConfigurationError,
23+
A365Error,
24+
A365SDKError,
25+
A365WorkflowExecutionError,
26+
)
27+
28+
__all__ = [
29+
"A365Error",
30+
"A365AuthenticationError",
31+
"A365ConfigurationError",
32+
"A365WorkflowExecutionError",
33+
"A365SDKError",
34+
]
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES.
2+
# All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
"""Configuration for Microsoft Agent 365 front-end."""
18+
19+
import logging
20+
from typing import Literal
21+
22+
from pydantic import Field, model_validator
23+
24+
from nat.data_models.common import OptionalSecretStr
25+
from nat.data_models.front_end import FrontEndBaseConfig
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
class A365FrontEndConfig(FrontEndBaseConfig, name="a365"):
31+
"""Microsoft Agent 365 front-end configuration.
32+
33+
This front-end integrates NAT workflows with Microsoft Agent 365 hosting framework,
34+
enabling workflows to receive notifications from Teams, Email, and Office 365 apps.
35+
36+
Authentication uses Entra ID (Azure AD) App Registration credentials (`app_id` and `app_password`)
37+
created when registering your bot in Azure Portal. The Microsoft Agents SDK authenticates with
38+
Entra ID via `MsalConnectionManager` to enable bot communication with Teams and Office 365.
39+
"""
40+
41+
host: str = Field(
42+
default="localhost",
43+
description="Host to bind the server to (default: localhost)"
44+
)
45+
port: int = Field(
46+
default=3978,
47+
description="Port to bind the server to (default: 3978)",
48+
ge=0,
49+
le=65535
50+
)
51+
app_id: str = Field(
52+
...,
53+
description="Entra ID Application (client) ID from your Azure App Registration. "
54+
"This is the Application ID created when registering your bot in Azure Portal."
55+
)
56+
app_password: OptionalSecretStr = Field(
57+
default=None,
58+
description="Entra ID client secret (password) from your Azure App Registration. "
59+
"This authenticates your bot with Entra ID via Microsoft Bot Framework. "
60+
"Can also be set via A365_APP_PASSWORD environment variable."
61+
)
62+
tenant_id: str | None = Field(
63+
default=None,
64+
description="Azure tenant ID (optional, defaults to 'common' for multi-tenant). "
65+
"Specify your tenant ID for single-tenant apps, or leave None for multi-tenant."
66+
)
67+
log_level: str = Field(
68+
default="INFO",
69+
description="Log level for the server (default: INFO)"
70+
)
71+
enable_notifications: bool = Field(
72+
default=True,
73+
description="Enable A365 notification handlers (email, Word, Excel, PowerPoint, lifecycle)"
74+
)
75+
notification_workflow: str | None = Field(
76+
default=None,
77+
description="Optional workflow name to route notifications to. If not specified, uses the default workflow."
78+
)
79+
runner_class: str | None = Field(
80+
default=None,
81+
description="Custom worker class for handling A365 setup (default: built-in worker). "
82+
"Specify as 'module.path.ClassName' to use a custom worker implementation."
83+
)
84+
85+
@model_validator(mode="after")
86+
def validate_security_configuration(self):
87+
"""Validate security configuration to prevent accidental misconfigurations."""
88+
localhost_hosts = {"localhost", "127.0.0.1", "::1"}
89+
90+
# Warn if binding to non-localhost interface
91+
# Note: Microsoft Agents SDK handles authentication, but binding to public interfaces
92+
# should be done with caution and proper network security measures
93+
if self.host not in localhost_hosts:
94+
logger.warning(
95+
"A365 front-end is configured to bind to '%s' (non-localhost interface). "
96+
"Ensure proper network security measures are in place (firewall rules, "
97+
"reverse proxy with TLS, etc.). For local development, consider binding to localhost.",
98+
self.host
99+
)
100+
101+
# Warn about default port in production-like scenarios
102+
if self.host not in localhost_hosts and self.port == 3978:
103+
logger.warning(
104+
"A365 front-end is using default port 3978 on a non-localhost interface. "
105+
"Consider using a non-standard port for production deployments."
106+
)
107+
108+
return self

0 commit comments

Comments
 (0)