Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,63 @@
# GitHub Copilot Instructions for Agent365-dotnet

The Microsoft Agent 365 SDK (C#/.NET) extends the Microsoft 365 Agents SDK with enterprise capabilities across four modules: **Observability**, **Notifications**, **Runtime**, and **Tooling**. Packages publish to NuGet under the `Microsoft.Agents.A365.*` prefix.

## Build, Test, and Lint

All commands run from the repository root unless noted. Requires the .NET 8.0.100 SDK (pinned in `src/global.json`).

```bash
# Build / test the whole solution
dotnet build src/Microsoft.Agents.A365.Sdk.sln
dotnet test src/Microsoft.Agents.A365.Sdk.sln

# Build script (traversal build via src/dirs.proj) — preferred for full runs
./build/build.ps1 # Release build
./build/build.ps1 -Clean -Restore -Test # clean rebuild + tests
./build/build.ps1 -Pack # produce NuGet packages

# Run all tests in ONE project
dotnet test src/Tests/Microsoft.Agents.A365.Runtime.Tests/Microsoft.Agents.A365.Runtime.Tests.csproj

# Run a SINGLE test or test class (xUnit primary; some MSTest)
dotnet test src/Microsoft.Agents.A365.Sdk.sln --filter "FullyQualifiedName~TenantContextHelperTests"
dotnet test src/Microsoft.Agents.A365.Sdk.sln --filter "Name=Extract_Returns_TenantId"

# Format check (enforced as a pre-commit hook)
dotnet format src/Microsoft.Agents.A365.Sdk.sln --verify-no-changes
```

CI (`.github/workflows/ci.yml`) builds and tests in **Release** with `--no-restore`/`--no-build`, then packs. Some Observability/Tooling tests read `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, and `AZURE_OPENAI_DEPLOYMENT` from the environment.

Pre-commit hooks (`.pre-commit-config.yaml`, install with `pip install pre-commit && pre-commit install`) run gitleaks (secret scanning), whitespace/EOL fixers, and `dotnet format`.

## Architecture

The SDK follows a consistent **Core + Extensions** pattern. Each module has a `Core`/`Runtime` package with base functionality and per-framework extension packages (SemanticKernel, AgentFramework, AzureAIFoundry, OpenAI). Source lives under `src/<Module>/`:

- **Runtime** (`src/Runtime/`) — multi-tenant context extraction (`TenantContextHelper` pulls tenant/worker IDs from `HttpContext` claims/headers/items) and the result pattern (`OperationResult` / `OperationError`).
- **Observability** (`src/Observability/`) — OpenTelemetry distributed tracing. Configured via a fluent `Builder` API; tracing uses disposable scope classes (`InvokeAgentScope`, `InferenceScope`, `ExecuteToolScope`) that auto-end spans on dispose. `BaggageMiddleware` seeds tenant/agent context into OTel baggage. A custom `Agent365Exporter` (gated by the `EnableAgent365Exporter` env var) exports spans.
- **Notifications** (`src/Notification/`) — event routing for M365 (Teams, email, Office) via `AgentNotification`; sub-channels like `agents:email`, `agents:word`, `agents:excel`, `agents:powerpoint`.
- **Tooling** (`src/Tooling/`) — Model Context Protocol (MCP) server discovery and tool registration via `IMcpToolServerConfigurationService`, with framework-specific registration extensions.

Build is a **traversal build**: `src/dirs.proj` (Microsoft.Build.Traversal) references each module's `dirs.proj`. Projects are auto-discovered, but the solution file is still maintained manually (see rules below). Versioning is automatic via Nerdbank.GitVersioning (nbgv) from git history — never hardcode versions.

Detailed design docs: `docs/design.md`; build internals: `build/BUILD.md`.

## Key Conventions

- **Central package management**: ALL NuGet versions live in `src/Directory.Packages.props`. Add a version there, then reference the package without a `Version` attribute in the `.csproj`. Never put versions in project files.
- **Common build props** (`src/Directory.Build.props`): `TreatWarningsAsErrors=true`, `Nullable=enable`, and `GenerateDocumentationFile=true` are global. Warnings — including invalid XML-doc `cref`s (CS1574) — fail the build. Write valid XML doc comments.
- **Target frameworks**: most packages target `net8.0`; some Runtime/Hosting packages target `netstandard2.0` (no implicit usings; `LangVersion` 8.0). Guard framework-specific code accordingly.
- **Observability export config — coordinated change required**: these three must stay in sync. If you change ONE, verify the other two:
| Constant | Location |
|---|---|
| `ProdObservabilityScope` (via `GetObservabilityAuthenticationScope()`) | `src/Observability/Runtime/Common/EnvironmentUtils.cs` |
| `DefaultEndpointHost` | `src/Observability/Runtime/Tracing/Exporters/Agent365ExporterOptions.cs` |
| Export URL path (`BuildEndpointPath()`) | `src/Observability/Runtime/Tracing/Exporters/Agent365ExporterCore.cs` |
Snapshot tests in `src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Tracing/Exporters/ExportConfigConsistencyTests.cs` catch accidental drift but not intentional-but-incomplete updates — confirm values are correct for the target environment.
- **Tests** mirror source under `src/Tests/` (e.g. `Microsoft.Agents.A365.Runtime.Tests`). xUnit is primary (some MSTest), with Moq for mocking and FluentAssertions for assertions.

## Coding agent rules
- Before committing changes, ensure that the solution `src/Microsoft.Agents.A365.Sdk.sln` builds: `dotnet build src/Microsoft.Agents.A365.Sdk.sln`
- Before committing changes, ensure that all tests pass: `dotnet test src/Microsoft.Agents.A365.Sdk.sln`
Expand Down
5 changes: 4 additions & 1 deletion src/Observability/Runtime/Common/ExportFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,10 @@ public string FormatLogData(IDictionary<string, object?> data)
SpanId = data["SpanId"],
ParentSpanId = data["ParentSpanId"],
TraceId = data.TryGetValue("TraceId", out var traceIdObj) ? traceIdObj : null,
Kind = data.TryGetValue("SpanKind", out var spanKindObj) && spanKindObj != null ? spanKindObj : SpanKindConstants.Client
Kind = data.TryGetValue("SpanKind", out var spanKindObj) && spanKindObj != null ? spanKindObj : SpanKindConstants.Client,
Status = data.TryGetValue("Status", out var statusObj) && statusObj != null
? statusObj
: new Dictionary<string, object> { { "code", 0 }, { "message", "" } }
};

return SerializePayload(payload);
Expand Down
21 changes: 20 additions & 1 deletion src/Observability/Runtime/DTOs/BaseData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ public BaseData(
/// </summary>
public string? TraceId { get; }

/// <summary>
/// Gets or sets the OpenTelemetry status code for the operation. Defaults to
/// <see cref="SpanStatusCode.Unset"/> (0); set to <see cref="SpanStatusCode.Error"/> (2)
/// when an error is recorded.
/// </summary>
public SpanStatusCode StatusCode { get; set; } = SpanStatusCode.Unset;

/// <summary>
/// Gets or sets the OpenTelemetry status message. Per the OTel spec this is only populated for an error status.
/// </summary>
public string? StatusMessage { get; set; }

/// <summary>
/// Gets the duration of the operation if both start and end times are provided.
/// </summary>
Expand All @@ -102,7 +114,14 @@ public BaseData(
{ "ParentSpanId", ParentSpanId },
{ "TraceId", TraceId },
{ "SpanKind", SpanKind },
{ "Duration", Duration }
{ "Duration", Duration },
{
"Status", new Dictionary<string, object>
{
{ "code", (int)StatusCode },
{ "message", StatusMessage ?? "" }
}
}
};

return dict;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class ApplyGuardrailDataBuilder : BaseDataBuilder<ApplyGuardrailData>
/// <param name="extraAttributes">Optional dictionary of extra attributes.</param>
/// <param name="spanKind">Optional span kind override.</param>
/// <param name="traceId">Optional trace ID for distributed tracing.</param>
/// <param name="error">Optional exception describing a failure; sets an OTel error status and the <c>error.type</c> attribute.</param>
/// <returns>An ApplyGuardrailData object containing all telemetry data.</returns>
public static ApplyGuardrailData Build(
GuardrailDetails guardrailDetails,
Expand All @@ -42,11 +43,12 @@ public static ApplyGuardrailData Build(
CallerDetails? callerDetails = null,
IDictionary<string, object?>? extraAttributes = null,
string? spanKind = null,
string? traceId = null)
string? traceId = null,
Exception? error = null)
{
var attributes = BuildAttributes(guardrailDetails, agentDetails, conversationId, channel, callerDetails, extraAttributes);

return new ApplyGuardrailData(parentSpanId, attributes, startTime, endTime, spanId, spanKind, traceId);
return ApplyStatus(new ApplyGuardrailData(parentSpanId, attributes, startTime, endTime, spanId, spanKind, traceId), error);
}

private static Dictionary<string, object?> BuildAttributes(
Expand Down
16 changes: 16 additions & 0 deletions src/Observability/Runtime/DTOs/Builders/BaseDataBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@ namespace Microsoft.Agents.A365.Observability.Runtime.DTOs.Builders
public abstract class BaseDataBuilder<T> where T : BaseData
{

/// <summary>
/// Applies an OpenTelemetry status to the built data based on an optional error, and records
/// the <c>error.type</c> attribute when an error is present. When <paramref name="error"/> is
/// <c>null</c> the status is left as <see cref="SpanStatusCode.Unset"/>.
/// </summary>
/// <param name="data">The telemetry data to annotate.</param>
/// <param name="error">Optional exception describing a failure for the operation.</param>
/// <returns>The same <paramref name="data"/> instance, for fluent use.</returns>
protected static T ApplyStatus(T data, Exception? error)
{
var status = SpanStatusBuilder.FromError(error, data.Attributes);
data.StatusCode = status.Code;
data.StatusMessage = status.Message;
return data;
}

/// <summary>
/// Adds attributes for input messages.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class ExecuteInferenceDataBuilder : BaseDataBuilder<ExecuteInferenceData>
/// <param name="callerDetails">Optional details about the caller.</param>
/// <param name="extraAttributes">Optional dictionary of extra attributes.</param>
/// <param name="traceId">Optional trace ID for distributed tracing.</param>
/// <param name="error">Optional exception describing a failure; sets an OTel error status and the <c>error.type</c> attribute.</param>
/// <returns>An ExecuteInferenceData object containing all telemetry data.</returns>
public static ExecuteInferenceData Build(
InferenceCallDetails inferenceCallDetails,
Expand All @@ -45,7 +46,8 @@ public static ExecuteInferenceData Build(
string? thoughtProcess = null,
CallerDetails? callerDetails = null,
IDictionary<string, object?>? extraAttributes = null,
string? traceId = null)
string? traceId = null,
Exception? error = null)
{
var attributes = BuildAttributes(
inferenceCallDetails,
Expand All @@ -58,7 +60,7 @@ public static ExecuteInferenceData Build(
callerDetails,
extraAttributes);

return new ExecuteInferenceData(attributes, startTime, endTime, spanId, parentSpanId, traceId);
return ApplyStatus(new ExecuteInferenceData(attributes, startTime, endTime, spanId, parentSpanId, traceId), error);
}

private static Dictionary<string, object?> BuildAttributes(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public class ExecuteToolDataBuilder : BaseDataBuilder<ExecuteToolData>
/// <param name="extraAttributes">Optional dictionary of extra attributes.</param>
/// <param name="spanKind">Optional span kind override. Use <see cref="SpanKindConstants.Internal"/> or <see cref="SpanKindConstants.Client"/> as appropriate.</param>
/// <param name="traceId">Optional trace ID for distributed tracing.</param>
/// <param name="error">Optional exception describing a failure; sets an OTel error status and the <c>error.type</c> attribute.</param>
/// <returns>An ExecuteToolData object containing all telemetry data.</returns>
public static ExecuteToolData Build(
ToolCallDetails toolCallDetails,
Expand All @@ -47,11 +48,12 @@ public static ExecuteToolData Build(
CallerDetails? callerDetails = null,
IDictionary<string, object?>? extraAttributes = null,
string? spanKind = null,
string? traceId = null)
string? traceId = null,
Exception? error = null)
{
var attributes = BuildAttributes(toolCallDetails, agentDetails, conversationId, responseContent, channel, callerDetails, extraAttributes);

return new ExecuteToolData(attributes, startTime, endTime, spanId, parentSpanId, spanKind, traceId);
return ApplyStatus(new ExecuteToolData(attributes, startTime, endTime, spanId, parentSpanId, spanKind, traceId), error);
}

private static Dictionary<string, object?> BuildAttributes(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public class InvokeAgentDataBuilder : BaseDataBuilder<InvokeAgentData>
/// <param name="extraAttributes">Optional dictionary of extra attributes.</param>
/// <param name="spanKind">Optional span kind override. Use <see cref="SpanKindConstants.Client"/> or <see cref="SpanKindConstants.Server"/> as appropriate.</param>
/// <param name="traceId">Optional trace ID for distributed tracing.</param>
/// <param name="error">Optional exception describing a failure; sets an OTel error status and the <c>error.type</c> attribute.</param>
/// <returns>An InvokeAgentData object containing all telemetry data.</returns>
public static InvokeAgentData Build(
InvokeAgentScopeDetails invokeAgentScopeDetails,
Expand All @@ -48,7 +49,8 @@ public static InvokeAgentData Build(
string? parentSpanId = null,
IDictionary<string, object?>? extraAttributes = null,
string? spanKind = null,
string? traceId = null)
string? traceId = null,
Exception? error = null)
{
var attributes = BuildAttributes(
invokeAgentScopeDetails,
Expand All @@ -60,14 +62,14 @@ public static InvokeAgentData Build(
outputMessages,
extraAttributes);

return new InvokeAgentData(
return ApplyStatus(new InvokeAgentData(
attributes,
startTime,
endTime,
spanId,
parentSpanId,
spanKind,
traceId);
traceId), error);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class OutputDataBuilder : BaseDataBuilder<OutputData>
/// <param name="parentSpanId">Optional parent span ID for distributed tracing.</param>
/// <param name="extraAttributes">Optional dictionary of extra attributes.</param>
/// <param name="traceId">Optional trace ID for distributed tracing.</param>
/// <param name="error">Optional exception describing a failure; sets an OTel error status and the <c>error.type</c> attribute.</param>
/// <returns>An OutputData object containing all telemetry data.</returns>
public static OutputData Build(
AgentDetails agentDetails,
Expand All @@ -42,11 +43,12 @@ public static OutputData Build(
string? spanId = null,
string? parentSpanId = null,
IDictionary<string, object?>? extraAttributes = null,
string? traceId = null)
string? traceId = null,
Exception? error = null)
{
var attributes = BuildAttributes(agentDetails, response, conversationId, channel, callerDetails, extraAttributes);

return new OutputData(attributes, startTime, endTime, spanId, parentSpanId, traceId);
return ApplyStatus(new OutputData(attributes, startTime, endTime, spanId, parentSpanId, traceId), error);
}

private static Dictionary<string, object?> BuildAttributes(
Expand Down
57 changes: 57 additions & 0 deletions src/Observability/Runtime/DTOs/Builders/SpanStatusBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using Azure;
using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes;

namespace Microsoft.Agents.A365.Observability.Runtime.DTOs.Builders
{
/// <summary>
/// Builds an OpenTelemetry-compliant <see cref="SpanStatus"/> from an exception and, for error
/// statuses, records the <c>error.type</c> attribute on the span attributes.
/// </summary>
/// <remarks>
/// Centralizes the exception-to-status mapping so that the ETW DTO logging path stays consistent
/// with the Activity-based scope path (<see cref="OpenTelemetryScope.RecordError(Exception)"/>).
/// </remarks>
public static class SpanStatusBuilder
{
/// <summary>
/// Creates a <see cref="SpanStatus"/> from an optional exception.
/// </summary>
/// <param name="error">
/// The exception to record. When <c>null</c>, an <see cref="SpanStatusCode.Unset"/> status is
/// returned and <paramref name="attributes"/> is left untouched.
/// </param>
/// <param name="attributes">
/// Optional span attributes dictionary. When an error is supplied and this is non-<c>null</c>, the
/// <c>error.type</c> attribute is written following OTel semantic conventions.
/// </param>
/// <returns>
/// An <see cref="SpanStatusCode.Error"/> status (with the exception message) when <paramref name="error"/>
/// is non-<c>null</c>; otherwise an <see cref="SpanStatusCode.Unset"/> status.
/// </returns>
public static SpanStatus FromError(Exception? error, IDictionary<string, object?>? attributes = null)
{
if (error == null)
{
return new SpanStatus(SpanStatusCode.Unset);
}

// Mirrors OpenTelemetryScope.RecordError: prefer the HTTP status from a RequestFailedException,
// otherwise fall back to the exception's full type name.
var errorType = error is RequestFailedException requestFailed && requestFailed.Status != 0
? requestFailed.Status.ToString()
: error.GetType().FullName ?? "error";

if (attributes != null)
{
attributes[OpenTelemetryConstants.ErrorTypeKey] = errorType;
}

return new SpanStatus(SpanStatusCode.Error, error.Message);
}
}
}
Loading
Loading