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
109 changes: 70 additions & 39 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,38 @@ All notable changes to the Agent365 TypeScript SDK will be documented in this fi
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] - 2026-04-15

### Breaking Changes (`@microsoft/agents-a365-observability`)

- **New permission required: `Agent365.Observability.OtelWrite`** — The observability exporter now requires this scope as both a delegated and application permission on your agent blueprint. See [Upgrade Instructions](#upgrade-instructions-observability-permission-for-existing-agents) below.

---

<a id="upgrade-instructions-observability-permission-for-existing-agents"></a>

### Upgrade Instructions: Observability Permission for Existing Agents

Existing agent blueprints need `Agent365.Observability.OtelWrite` granted as both a **delegated permission** and an **application permission**. Choose either option below.

#### Option A — Agent 365 CLI (requires both config files)

Requires `a365.config.json` and `a365.generated.config.json` in your config directory, a Global Administrator account, and [Agent 365 CLI v1.1.139-preview](https://www.nuget.org/packages/Microsoft.Agents.A365.DevTools.Cli/1.1.139-preview) or later.

```bash
a365 setup admin --config-dir "<path-to-config-dir>"
```

This grants all missing permissions including the new Observability scopes.

#### Option B — Entra Portal (no config files required)

Requires Global Administrator access to the blueprint app registration.

1. Go to **Entra portal** > **App registrations** > select your Blueprint app
2. Go to **API permissions** > **Add a permission** > **APIs my organization uses** > search for `9b975845-388f-4429-889e-eab1ef63949c`
3. Select **Delegated permissions** > check `Agent365.Observability.OtelWrite` > **Add permissions**
4. Repeat step 2–3, this time select **Application permissions** > check `Agent365.Observability.OtelWrite` > **Add permissions**
5. Click **Grant admin consent** and confirm

Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability.OtelWrite` (Application) should show `Granted` status.

---

## [Unreleased]
## [2.0.0] - Unreleased

### Breaking Changes (`@microsoft/agents-a365-observability`, `@microsoft/agents-a365-observability-hosting`)

- **OBS exports always use `/observabilityService`** - The `useS2SEndpoint` option is
deprecated and ignored, even when `false`. Batch and per-request exports no longer
select or fall back to `/observability`. Provide an app-only OBS token independently
of your agent's workload auth; the S2S service rejects delegated `scp` tokens.
- **Per-request OBS requires the configured app-only resolver** - Both export modes
use `withTokenResolver(...)` or `exporterOptions.tokenResolver`, with the builder
method taking precedence. `Agent365Exporter` no longer reads tokens from
`runWithExportToken`/`updateExportToken`. Missing resolvers fail configuration;
empty tokens or acquisition failures fail export without delegated fallback.
The exporter invokes the resolver on every export batch, so resolvers must
cache the acquired token and refresh only near expiry.
`ObservabilityManager.start(options)` now forwards `options.exporterOptions`,
which it previously ignored.
Workload OBO and custom-exporter context helpers are otherwise unchanged.
- **Hosting OBS token cache requires an app-only resolver** -
`RefreshObservabilityToken(agentId, tenantId, tokenResolver)` replaces the
`TurnContext`/`Authorization` overload, which is removed. JavaScript callers that
still pass it get a one-time error log and no token instead of an exception.
`RefreshObservabilityToken` throws when acquisition fails; call it from the
exporter's `tokenResolver` or wrap it in try/catch on the request path.

### Fixed (`@microsoft/agents-a365-observability`)

- **`@opentelemetry/core` is now a declared dependency** - The exporter imports it at
runtime, so installs where npm did not hoist another copy failed with
`MODULE_NOT_FOUND` when loading the exporter.

## [1.0.0] - 2026-04-30

### Breaking Changes (`@microsoft/agents-a365-tooling`)

Expand Down Expand Up @@ -164,6 +157,44 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability.
- `EnhancedAgentDetails` is now an alias of `AgentDetails` and marked as deprecated. Existing imports continue to work without breaking changes; migrate to `AgentDetails` when convenient.


## [0.2.0] - 2026-04-15

### Breaking Changes (`@microsoft/agents-a365-observability`)

- **New permission required: `Agent365.Observability.OtelWrite`** — The observability exporter now requires this scope as both a delegated and application permission on your agent blueprint. See [Upgrade Instructions](#upgrade-instructions-observability-permission-for-existing-agents) below.

---

<a id="upgrade-instructions-observability-permission-for-existing-agents"></a>

### Upgrade Instructions: Observability Permission for Existing Agents

Existing agent blueprints need `Agent365.Observability.OtelWrite` granted as both a **delegated permission** and an **application permission**. Choose either option below.

#### Option A — Agent 365 CLI (requires both config files)

Requires `a365.config.json` and `a365.generated.config.json` in your config directory, a Global Administrator account, and [Agent 365 CLI v1.1.139-preview](https://www.nuget.org/packages/Microsoft.Agents.A365.DevTools.Cli/1.1.139-preview) or later.

```bash
a365 setup admin --config-dir "<path-to-config-dir>"
```

This grants all missing permissions including the new Observability scopes.

#### Option B — Entra Portal (no config files required)

Requires Global Administrator access to the blueprint app registration.

1. Go to **Entra portal** > **App registrations** > select your Blueprint app
2. Go to **API permissions** > **Add a permission** > **APIs my organization uses** > search for `9b975845-388f-4429-889e-eab1ef63949c`
3. Select **Delegated permissions** > check `Agent365.Observability.OtelWrite` > **Add permissions**
4. Repeat step 2–3, this time select **Application permissions** > check `Agent365.Observability.OtelWrite` > **Add permissions**
5. Click **Grant admin consent** and confirm

Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability.OtelWrite` (Application) should show `Granted` status.

---

## [0.1.0] - 2025-01-03

### Added
Expand Down
55 changes: 50 additions & 5 deletions packages/agents-a365-observability-hosting/docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,18 +107,63 @@ const agentPairs = getTargetAgentBaggagePairs(turnContext);

### AgenticTokenCacheInstance ([AgenticTokenCache.ts](../src/caching/AgenticTokenCache.ts))

Token caching for improved performance:
Cache app-only OBS tokens independently of the workload's AI Teammate or OBO
authorization. The `TurnContext`/`Authorization` overload was removed in 2.0.0:
TypeScript callers get a compile error, and untyped callers get a one-time error log
and no token instead of a delegated token that S2S rejects. `RefreshObservabilityToken`
throws when acquisition fails; call it from the exporter's `tokenResolver` or wrap it in
try/catch on the request path.

```typescript
import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-hosting';

// Cache token with key
AgenticTokenCacheInstance.set('cache-key', 'token-value', ttlMs);
// acquireAppOnlyObsToken is your app-only token acquisition callback.
// It receives (agentId, tenantId, scopes) and returns the final OBS access token.
await AgenticTokenCacheInstance.RefreshObservabilityToken(
agentId, tenantId, acquireAppOnlyObsToken
);

// Retrieve cached token
const token = AgenticTokenCacheInstance.get('cache-key');
const token = AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId);
```

For a blueprint-backed agent, acquire a blueprint exchange assertion with
`fmi_path=agentId`, then use it as `client_assertion` in an instance
`client_credentials` request for the OBS `/.default` scope. Do not send the
intermediate assertion, a blueprint token, or a `user_fic`/OBO token to OBS.
The final token's application identity must match `agentId`, its tenant must
match `tenantId`, and its audience must be OBS. An eligible Agent 365-registered
instance can use a roleless app token when service policy permits; an
`Agent365.Observability.OtelWrite` grant is not a universal prerequisite. Entra
identity creation alone does not establish instance registration or service access.
The resolver must validate app-only identity: accept `idtyp=app`, or, when `idtyp` is absent,
either a non-empty `roles` array or a non-empty `oid` equal to `sub`; reject any other `idtyp`
value. It must also reject delegated `scp` tokens, and check audience and lifetime before
returning a token. The cache does not perform token authentication or authorization.
Acquisition failures propagate to the caller and never trigger delegated authentication.

When migrating, replace only the OBS refresh call, not workload MCP/Graph/OBO
authorization. Configure an OBS resolver in both batch and per-request modes.
It should refresh the app-only cache at export time before returning its token,
so long-running requests do not depend on a token acquired at turn start:

```typescript
builder.withTokenResolver(async (agentId, tenantId) => {
await AgenticTokenCacheInstance.RefreshObservabilityToken(
agentId, tenantId, acquireAppOnlyObsToken
);
return AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId);
});
```

`Agent365Exporter` ignores tokens in `runWithExportToken`; those context helpers
remain available for custom exporters, not as an OBS authentication fallback.
An enabled exporter without an explicit resolver fails configuration.
Both modes use the S2S OTLP
route even if the deprecated `useS2SEndpoint` option is false. Missing tokens and
failed acquisition report export failure without sending a request; HTTP
401/403/404 never select an OBO fallback. Check instance registration and service
policy rather than adding OBS permissions automatically.

## Tenant ID Resolution

The package extracts tenant ID from multiple sources:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@
// Licensed under the MIT License.
// ------------------------------------------------------------------------------

import { TurnContext, Authorization } from '@microsoft/agents-hosting';
import { logger, formatError, ObservabilityConfiguration, defaultObservabilityConfigurationProvider } from '@microsoft/agents-a365-observability';
import { IConfigurationProvider } from '@microsoft/agents-a365-runtime';
import {
logger, formatError, defaultObservabilityConfigurationProvider,
type ObservabilityConfiguration, type TokenResolver,
} from '@microsoft/agents-a365-observability';
import type { IConfigurationProvider } from '@microsoft/agents-a365-runtime';

/** Acquires an app-only OBS token; must not perform user_fic or OBO authentication. */
export type ObservabilityTokenResolver = (
agentId: string,
tenantId: string,
scopes: readonly string[]
) => ReturnType<TokenResolver>;

interface CacheEntry {
scopes: string[];
Expand All @@ -32,6 +41,7 @@ export class AgenticTokenCache {
private readonly _maxCacheSize = 10_000;
private readonly _maxExpSeconds = 86_400; // 24 hours
private readonly _keyLocks = new Map<string, Promise<unknown>>();
private _removedOverloadLogged = false;
private readonly _configProvider: IConfigurationProvider<ObservabilityConfiguration>;

/**
Expand Down Expand Up @@ -64,28 +74,35 @@ export class AgenticTokenCache {
return entry.token;
}

/**
* Refreshes an app-only OBS token independently of the current user's authorization.
* The resolver receives the configured OBS scopes and must acquire a token for
* the exporting agent identity, not its blueprint or the workload's user.
*
* @throws When the resolver fails or returns no token. Call this from the exporter's
* `tokenResolver`, where a failure fails that export, or wrap it in try/catch on the
* request path.
*/
public async RefreshObservabilityToken(
agentId: string,
tenantId: string,
turnContext: TurnContext,
authorization: Authorization,
scopes: string[],
authHandlerName: string = 'agentic'
tokenResolver: ObservabilityTokenResolver
): Promise<void> {
const key = AgenticTokenCache.makeKey(agentId, tenantId);
if (!authorization) {
throw new Error('[AgenticTokenCache] Authorization not set');
if (typeof tokenResolver !== 'function') {
// Untyped callers can still pass the TurnContext/Authorization overload removed in 2.0.0.
this.logRemovedOverloadOnce();
return;
}
if (!turnContext) {
throw new Error('[AgenticTokenCache] TurnContext not set');
if (!agentId?.trim() || !tenantId?.trim()) {
throw new Error('[AgenticTokenCache] Agent and tenant IDs are required');
}
const key = AgenticTokenCache.makeKey(agentId, tenantId);
return this.withKeyLock<void>(key, async () => {
let entry = this._map.get(key);
if (!entry) {
const effectiveScopes = (scopes && scopes.length > 0) ? scopes : [...this._configProvider.getConfiguration().observabilityAuthenticationScopes];
const effectiveScopes = [...this._configProvider.getConfiguration().observabilityAuthenticationScopes];
if (!Array.isArray(effectiveScopes) || effectiveScopes.length === 0) {
logger.error('[AgenticTokenCache] No valid scopes');
return;
throw new Error('[AgenticTokenCache] No valid scopes');
}
entry = { scopes: effectiveScopes };
if (this._map.size >= this._maxCacheSize) {
Expand All @@ -97,8 +114,7 @@ export class AgenticTokenCache {
this._map.set(key, entry);
}
if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) {
logger.error('[AgenticTokenCache] Entry has invalid scopes');
return;
throw new Error('[AgenticTokenCache] Entry has invalid scopes');
}

if (entry.token && !this.isExpired(entry)) {
Expand All @@ -107,21 +123,19 @@ export class AgenticTokenCache {

const maxRetries = 2;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
logger.info(`[AgenticTokenCache] Exchanging token attempt ${attempt + 1}/${maxRetries + 1}`);
logger.info(`[AgenticTokenCache] Acquiring app-only token attempt ${attempt + 1}/${maxRetries + 1}`);
try {
const tokenResponse = await authorization.exchangeToken(turnContext, authHandlerName, { scopes: entry.scopes });
if (!tokenResponse?.token) {
logger.error('[AgenticTokenCache] Undefined token returned');
entry.token = undefined;
entry.expiresOn = undefined;
break;
const token = await tokenResolver(agentId, tenantId, [...entry.scopes]);
if (!token?.trim()) {
throw new Error('[AgenticTokenCache] App-only token resolver returned no token');
}
entry.token = tokenResponse.token;
entry.token = token;
entry.acquiredOn = Date.now();
const oboExp = this.decodeExp(entry.token);
if (oboExp) {
entry.expiresOn = oboExp * 1000;
const exp = this.decodeExp(token);
if (exp) {
entry.expiresOn = exp * 1000;
} else {
entry.expiresOn = undefined;
logger.warn('[AgenticTokenCache] No exp claim, fallback TTL');
}
logger.info('[AgenticTokenCache] Token cached');
Expand All @@ -136,7 +150,8 @@ export class AgenticTokenCache {
logger.error('[AgenticTokenCache] Non-retriable failure', formatError(e));
entry.token = undefined;
entry.expiresOn = undefined;
break;
entry.acquiredOn = undefined;
throw e;
}
}
});
Expand Down Expand Up @@ -205,6 +220,14 @@ export class AgenticTokenCache {
return false;
}

private logRemovedOverloadOnce(): void {
if (this._removedOverloadLogged) {
return;
}
this._removedOverloadLogged = true;
logger.error('[AgenticTokenCache] RefreshObservabilityToken(agentId, tenantId, turnContext, authorization, ...) was removed in 2.0.0 and does nothing; S2S OBS needs an app-only token. Call RefreshObservabilityToken(agentId, tenantId, tokenResolver) instead.');
}

private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
Expand Down
1 change: 1 addition & 0 deletions packages/agents-a365-observability-hosting/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './utils/BaggageBuilderUtils';
export * from './utils/ScopeUtils';
export * from './utils/TurnContextUtils';
export { AgenticTokenCache, AgenticTokenCacheInstance } from './caching/AgenticTokenCache';
export type { ObservabilityTokenResolver } from './caching/AgenticTokenCache';
export { BaggageMiddleware } from './middleware/BaggageMiddleware';
export { OutputLoggingMiddleware, A365_PARENT_SPAN_KEY, A365_AUTH_TOKEN_KEY } from './middleware/OutputLoggingMiddleware';
export { ObservabilityHostingManager } from './middleware/ObservabilityHostingManager';
Expand Down
Loading
Loading