Skip to content

Commit a3ab629

Browse files
sellakumaranclaude
andauthored
Improve agent registration reliability and UX (#401)
* Improve agent registration reliability and UX - --agent-registration-only: skip identity/permissions/project-settings steps; fall back to Graph API lookup when agenticAppId is missing; fail with error (not warning) when identity cannot be found - Bootstrap (--agent-name): merge agentBlueprintId + agenticAppId from generated config, not just agentRegistrationId - Fix agenticAppId/agenticUserId JSON key casing (camelCase); auto-migrate PascalCase keys in existing files - Add forceRefresh on Graph token acquisition to bypass WAM cache on 401 retry - Require AgentRegistration.ReadWrite.All in client app permissions; update tests New --agent-registration-only summary output: Agent Registration registered 'My Agent' Registration ID: a1b2c3... Agent identity: e5f6a7... Blueprint: c9d0e1... When agent identity is not found: Agent Registration not attempted — agent identity not found (run 'a365 setup all' to create it first) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address PR #401 review comments — scopes, retry, log levels, migration guards - Add BlueprintOperationScopes (excludes AgentRegistration.ReadWrite.All) to prevent MSAL consent errors on blueprint operations for apps not yet updated with the permission - Use BlueprintOperationScopes at 3 BlueprintSubcommand.cs token acquisition call sites - Fix forceRefresh: false on first registration attempt; true only on retry (via closure) - Promote agent registration failure summary lines from LogWarning to LogError - Remove stale AADSTS650053 comments in GraphApiService.cs and AuthenticationConstants.cs - Remove redundant .Append("AgentRegistration.ReadWrite.All") in SetupHelpers.cs - Use string.IsNullOrWhiteSpace for all 3 legacy config migration guards in ConfigService.cs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 314774d commit a3ab629

14 files changed

Lines changed: 266 additions & 127 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g
4242
### Fixed
4343
- `setup all --agent-name` re-runs no longer create a duplicate agent registration: the CLI now reads `agentRegistrationId` from `a365.generated.config.json` (when present) and checks for an existing registration before posting a new one.
4444
- `setup all` now skips agent registration with a clear warning when the agent identity ID is not available, instead of silently sending an invalid request. Retry with `a365 setup all --agent-registration-only` once the identity is ready.
45+
- `setup all --agent-registration-only` reliability fixes: stored IDs are now correctly read in bootstrap (`--agent-name`) mode; falls back to a Graph API lookup when `agenticAppId` is missing; skips identity, permission, and project-settings steps that don't apply.
4546

4647
### Removed
4748
- `a365 config` command family (`config init`, `config display`, `config permissions`) — replaced by `a365 setup all --agent-name` and `a365 setup permissions custom`.

src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -258,25 +258,29 @@ public static Command CreateCommand(
258258
await WriteBootstrapConfigFileAsync(nonDwConfig, config.FullName, logger);
259259
}
260260

261-
// Merge AgentRegistrationId from the existing generated config (if present) so
262-
// re-running with --agent-name does not create a duplicate registration. Blueprint
263-
// and identity are found by display name, but registration has no lookup endpoint —
264-
// the stored ID is the only idempotency key available for the registration step.
261+
// Merge stored IDs from the existing generated config (if present) so re-running
262+
// with --agent-name reuses previously created resources. Registration has no
263+
// lookup endpoint so the stored ID is the only idempotency key; blueprint and
264+
// identity IDs are needed for the --agent-registration-only API fallback.
265265
var bootstrapGenPath = Path.Combine(
266266
config.DirectoryName ?? Environment.CurrentDirectory,
267267
"a365.generated.config.json");
268-
if (File.Exists(bootstrapGenPath) && string.IsNullOrWhiteSpace(nonDwConfig.AgentRegistrationId))
268+
if (File.Exists(bootstrapGenPath))
269269
{
270270
try
271271
{
272272
var genConfig = await configService.LoadAsync(config.FullName, bootstrapGenPath);
273-
if (!string.IsNullOrWhiteSpace(genConfig.AgentRegistrationId))
273+
if (!string.IsNullOrWhiteSpace(genConfig.AgentRegistrationId) && string.IsNullOrWhiteSpace(nonDwConfig.AgentRegistrationId))
274274
nonDwConfig.AgentRegistrationId = genConfig.AgentRegistrationId;
275+
if (!string.IsNullOrWhiteSpace(genConfig.AgentBlueprintId) && string.IsNullOrWhiteSpace(nonDwConfig.AgentBlueprintId))
276+
nonDwConfig.AgentBlueprintId = genConfig.AgentBlueprintId;
277+
if (!string.IsNullOrWhiteSpace(genConfig.AgenticAppId) && string.IsNullOrWhiteSpace(nonDwConfig.AgenticAppId))
278+
nonDwConfig.AgenticAppId = genConfig.AgenticAppId;
275279
}
276280
catch (OperationCanceledException) { throw; }
277281
catch (Exception ex)
278282
{
279-
logger.LogDebug(ex, "Could not merge generated config in bootstrap mode; proceeding without stored registration ID.");
283+
logger.LogDebug(ex, "Could not merge generated config in bootstrap mode; proceeding without stored IDs.");
280284
}
281285
}
282286
}

src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,7 +1321,7 @@ public static async Task<bool> EnsureDelegatedConsentWithRetriesAsync(
13211321
objectId,
13221322
userObjectId: null,
13231323
ct,
1324-
scopes: AuthenticationConstants.RequiredClientAppPermissions);
1324+
scopes: AuthenticationConstants.BlueprintOperationScopes);
13251325

13261326
if (isOwner)
13271327
{
@@ -1518,7 +1518,7 @@ private static async Task EnsureOboScopeAsync(
15181518
{
15191519
using var appDoc = await graphApiService.GraphGetAsync(
15201520
tenantId, $"/v1.0/applications/{objectId}?$select=api", ct,
1521-
scopes: AuthenticationConstants.RequiredClientAppPermissions);
1521+
scopes: AuthenticationConstants.BlueprintOperationScopes);
15221522

15231523
var existingScopes = new JsonArray();
15241524

@@ -1562,7 +1562,7 @@ private static async Task EnsureOboScopeAsync(
15621562

15631563
var patched = await graphApiService.GraphPatchAsync(
15641564
tenantId, $"/v1.0/applications/{objectId}", patch, ct,
1565-
scopes: AuthenticationConstants.RequiredClientAppPermissions);
1565+
scopes: AuthenticationConstants.BlueprintOperationScopes);
15661566

15671567
if (patched)
15681568
logger.LogInformation("{Scope} scope added to blueprint", ConfigConstants.BlueprintOboScope);

src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs

Lines changed: 116 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -248,17 +248,49 @@ public static async Task<int> ExecuteAsync(SetupContext ctx)
248248
{
249249
ctx.Logger.LogInformation("NOTE: --agent-registration-only flag set. Skipping requirements, blueprint, and permissions steps.");
250250
ctx.Logger.LogInformation("");
251-
// Populate results so the summary shows previous steps as already completed
252-
ctx.Results.BlueprintCreated = true;
253-
ctx.Results.BlueprintAlreadyExisted = true;
254-
ctx.Results.BlueprintId = ctx.Config.AgentBlueprintId;
255-
ctx.Results.BlueprintDisplayName = ctx.Config.AgentBlueprintDisplayName;
256-
ctx.Results.BatchPermissionsPhase2Completed = true;
257-
ctx.Results.AdminConsentGranted = true;
258-
// Still check and prompt for consent even when skipping other steps — consent
259-
// is required for the registration call and may have been missed in a prior run.
260-
await EnsureConsentWithPromptAsync(ctx);
261-
await ExecuteAgentIdentityAndRegistrationAsync(ctx, specs);
251+
ctx.Results.PrerequisitesSkipped = true;
252+
ctx.Results.BatchPermissionsPhase1Completed = true;
253+
ctx.Results.PermissionGrantsSkipped = true;
254+
255+
// Fallback: if AgenticAppId is missing, try to find it via API using blueprint ID + display name.
256+
if (string.IsNullOrWhiteSpace(ctx.Config.AgenticAppId))
257+
{
258+
if (!string.IsNullOrWhiteSpace(ctx.Config.AgentBlueprintId))
259+
{
260+
var identityDisplayName = ctx.Config.AgentIdentityDisplayName ?? "Agent";
261+
ctx.Logger.LogInformation("Agent identity ID not in config. Querying by display name '{Name}'...", identityDisplayName);
262+
var foundId = await ctx.BlueprintService.FindExistingAgentIdentityAsync(
263+
ctx.Config.TenantId!,
264+
ctx.Config.AgentBlueprintId!,
265+
identityDisplayName,
266+
ctx.CancellationToken);
267+
if (!string.IsNullOrWhiteSpace(foundId))
268+
{
269+
ctx.Config.AgenticAppId = foundId;
270+
await ctx.ConfigService.SaveStateAsync(ctx.Config);
271+
ctx.Logger.LogInformation("Found agent identity (ID: {Id}). Using it for registration.", foundId);
272+
}
273+
}
274+
}
275+
276+
if (string.IsNullOrWhiteSpace(ctx.Config.AgenticAppId))
277+
{
278+
ctx.Logger.LogError("Agent identity ID is not set in config. Run 'a365 setup all' first to create the agent identity, then retry with --agent-registration-only.");
279+
ctx.Results.AgentIdentityFailed = true;
280+
ctx.Results.AgentRegistrationFailed = true;
281+
ctx.Results.Errors.Add("Agent identity ID not found in config. Run 'a365 setup all' (without --agent-registration-only) to create it first.");
282+
}
283+
else
284+
{
285+
ctx.Results.AgentIdentityCreated = true;
286+
ctx.Results.AgentIdentityAlreadyExisted = true;
287+
ctx.Results.AgentIdentityId = ctx.Config.AgenticAppId;
288+
ctx.Results.AgentIdentityDisplayName = ctx.Config.AgentIdentityDisplayName;
289+
ctx.Results.BlueprintId = ctx.Config.AgentBlueprintId;
290+
// Consent check required — AgentRegistration.ReadWrite.All must be consented for the registration token.
291+
await EnsureConsentWithPromptAsync(ctx);
292+
await ExecuteAgentIdentityAndRegistrationAsync(ctx, specs, skipIdentityAndPermissions: true);
293+
}
262294
}
263295
else
264296
{
@@ -349,88 +381,95 @@ public static async Task<int> ExecuteAsync(SetupContext ctx)
349381
/// Executes Steps 5-8: agent identity creation, permission grants, agent registration,
350382
/// and project settings sync. Called from both the normal path and the --agent-registration-only
351383
/// shortcut path.
384+
/// When <paramref name="skipIdentityAndPermissions"/> is true (--agent-registration-only),
385+
/// identity creation and permission grants are skipped — only registration and project settings run.
352386
/// </summary>
353387
private static async Task ExecuteAgentIdentityAndRegistrationAsync(
354388
SetupContext ctx,
355-
List<ResourcePermissionSpec> specs)
389+
List<ResourcePermissionSpec> specs,
390+
bool skipIdentityAndPermissions = false)
356391
{
357392
// Step 5: Create Agent Identity via Agent Identity Graph API.
358-
ctx.Logger.LogInformation("");
359-
360-
if (!string.IsNullOrWhiteSpace(ctx.Config.AgenticAppId))
361-
{
362-
ctx.Logger.LogInformation("Agent identity already created (ID: {AgentId}). Skipping.", ctx.Config.AgenticAppId);
363-
ctx.Results.AgentIdentityCreated = true;
364-
ctx.Results.AgentIdentityAlreadyExisted = true;
365-
ctx.Results.AgentIdentityId = ctx.Config.AgenticAppId;
366-
ctx.Results.AgentIdentityDisplayName = ctx.Config.AgentIdentityDisplayName;
367-
}
368-
else
393+
// Skipped when --agent-registration-only: identity result flags are pre-set by the caller.
394+
if (!skipIdentityAndPermissions)
369395
{
370-
var agentIdentityDisplayName = ctx.Config.AgentIdentityDisplayName
371-
?? "Agent";
372-
373-
// API-level idempotency: check if an identity with this display name already exists
374-
// for the blueprint. Handles re-runs where the generated config was cleared.
375-
var existingIdentityId = await ctx.BlueprintService.FindExistingAgentIdentityAsync(
376-
ctx.Config.TenantId!,
377-
ctx.Config.AgentBlueprintId!,
378-
agentIdentityDisplayName,
379-
ctx.CancellationToken);
396+
ctx.Logger.LogInformation("");
380397

381-
if (!string.IsNullOrWhiteSpace(existingIdentityId))
398+
if (!string.IsNullOrWhiteSpace(ctx.Config.AgenticAppId))
382399
{
383-
ctx.Logger.LogInformation("Found existing agent identity (ID: {AgentId}). Skipping creation.", existingIdentityId);
384-
ctx.Config.AgenticAppId = existingIdentityId;
385-
await ctx.ConfigService.SaveStateAsync(ctx.Config);
400+
ctx.Logger.LogInformation("Agent identity already created (ID: {AgentId}). Skipping.", ctx.Config.AgenticAppId);
386401
ctx.Results.AgentIdentityCreated = true;
387402
ctx.Results.AgentIdentityAlreadyExisted = true;
388-
ctx.Results.AgentIdentityId = existingIdentityId;
389-
ctx.Results.AgentIdentityDisplayName = agentIdentityDisplayName;
403+
ctx.Results.AgentIdentityId = ctx.Config.AgenticAppId;
404+
ctx.Results.AgentIdentityDisplayName = ctx.Config.AgentIdentityDisplayName;
390405
}
391406
else
392407
{
393-
// Agent identity creation via delegated flow (AgentIdentity.Create.All).
394-
// Agent ID Developer role is sufficient — client credentials are not required.
395-
ctx.Logger.LogInformation("Creating agent identity...");
396-
var agentId = await ctx.GraphApiService.CreateAgentIdentityDelegatedAsync(
408+
var agentIdentityDisplayName = ctx.Config.AgentIdentityDisplayName
409+
?? "Agent";
410+
411+
// API-level idempotency: check if an identity with this display name already exists
412+
// for the blueprint. Handles re-runs where the generated config was cleared.
413+
var existingIdentityId = await ctx.BlueprintService.FindExistingAgentIdentityAsync(
397414
ctx.Config.TenantId!,
398415
ctx.Config.AgentBlueprintId!,
399416
agentIdentityDisplayName,
400417
ctx.CancellationToken);
401418

402-
if (agentId is not null)
419+
if (!string.IsNullOrWhiteSpace(existingIdentityId))
403420
{
404-
ctx.Config.AgenticAppId = agentId;
421+
ctx.Logger.LogInformation("Found existing agent identity (ID: {AgentId}). Skipping creation.", existingIdentityId);
422+
ctx.Config.AgenticAppId = existingIdentityId;
405423
await ctx.ConfigService.SaveStateAsync(ctx.Config);
406424
ctx.Results.AgentIdentityCreated = true;
407-
ctx.Results.AgentIdentityId = agentId;
425+
ctx.Results.AgentIdentityAlreadyExisted = true;
426+
ctx.Results.AgentIdentityId = existingIdentityId;
408427
ctx.Results.AgentIdentityDisplayName = agentIdentityDisplayName;
409-
using (ctx.Logger.Indent())
410-
ctx.Logger.LogInformation("Agent identity created (ID: {AgentId})", agentId);
411-
ctx.Logger.LogInformation("");
412428
}
413-
else if (!ctx.Results.AgentIdentityFailed)
429+
else
414430
{
415-
ctx.Results.AgentIdentityFailed = true;
416-
ctx.Results.Warnings.Add("Agent identity creation failed. Ensure you have the Agent ID Developer or Agent ID Administrator role in this tenant.");
417-
ctx.Logger.LogWarning("Agent identity creation failed. Ensure you have the Agent ID Developer or Agent ID Administrator role in this tenant.");
431+
// Agent identity creation via delegated flow (AgentIdentity.Create.All).
432+
// Agent ID Developer role is sufficient — client credentials are not required.
433+
ctx.Logger.LogInformation("Creating agent identity...");
434+
var agentId = await ctx.GraphApiService.CreateAgentIdentityDelegatedAsync(
435+
ctx.Config.TenantId!,
436+
ctx.Config.AgentBlueprintId!,
437+
agentIdentityDisplayName,
438+
ctx.CancellationToken);
439+
440+
if (agentId is not null)
441+
{
442+
ctx.Config.AgenticAppId = agentId;
443+
await ctx.ConfigService.SaveStateAsync(ctx.Config);
444+
ctx.Results.AgentIdentityCreated = true;
445+
ctx.Results.AgentIdentityId = agentId;
446+
ctx.Results.AgentIdentityDisplayName = agentIdentityDisplayName;
447+
using (ctx.Logger.Indent())
448+
ctx.Logger.LogInformation("Agent identity created (ID: {AgentId})", agentId);
449+
ctx.Logger.LogInformation("");
450+
}
451+
else if (!ctx.Results.AgentIdentityFailed)
452+
{
453+
ctx.Results.AgentIdentityFailed = true;
454+
ctx.Results.Warnings.Add("Agent identity creation failed. Ensure you have the Agent ID Developer or Agent ID Administrator role in this tenant.");
455+
ctx.Logger.LogWarning("Agent identity creation failed. Ensure you have the Agent ID Developer or Agent ID Administrator role in this tenant.");
456+
}
418457
}
419458
}
420-
}
421459

422-
// Step 5a: Grant permissions to the agent identity, gated by authMode.
423-
if (!string.IsNullOrWhiteSpace(ctx.Config.AgenticAppId))
424-
{
425-
ctx.Results.EffectiveAuthMode = ctx.IsBothMode ? "both" : ctx.IsS2sMode ? "s2s" : "obo";
460+
// Step 5a: Grant permissions to the agent identity, gated by authMode.
461+
if (!string.IsNullOrWhiteSpace(ctx.Config.AgenticAppId))
462+
{
463+
ctx.Results.EffectiveAuthMode = ctx.IsBothMode ? "both" : ctx.IsS2sMode ? "s2s" : "obo";
426464

427-
// OBO and Both: principal-scoped delegated grants (no admin required).
428-
if (ctx.IsOboMode || ctx.IsBothMode)
429-
await GrantAgentIdentityPermissionsAsync(ctx, specs);
465+
// OBO and Both: principal-scoped delegated grants (no admin required).
466+
if (ctx.IsOboMode || ctx.IsBothMode)
467+
await GrantAgentIdentityPermissionsAsync(ctx, specs);
430468

431-
// S2S and Both: app role assignments (requires Global Admin; falls back to PowerShell instructions).
432-
if (ctx.IsS2sMode || ctx.IsBothMode)
433-
await GrantOrInstructAgentIdentityAppPermissionsAsync(ctx, specs);
469+
// S2S and Both: app role assignments (requires Global Admin; falls back to PowerShell instructions).
470+
if (ctx.IsS2sMode || ctx.IsBothMode)
471+
await GrantOrInstructAgentIdentityAppPermissionsAsync(ctx, specs);
472+
}
434473
}
435474

436475
// Step 6: Register agent via Graph API (copilot/agentRegistrations).
@@ -537,17 +576,21 @@ private static async Task ExecuteAgentIdentityAndRegistrationAsync(
537576
} // end else (AgenticAppId present)
538577

539578
// Step 6.5: Messaging endpoint registration — --m365 gated; no-op for non-M365 agents.
540-
await AllSubcommand.ExecuteMessagingEndpointStepAsync(ctx);
579+
// Skipped for --agent-registration-only (skipIdentityAndPermissions) — endpoint is already registered.
580+
if (!skipIdentityAndPermissions)
581+
await AllSubcommand.ExecuteMessagingEndpointStepAsync(ctx);
541582

542-
// Sync all settings (ServiceConnection, TokenValidation, Agent365Observability) to the app config file.
543-
ctx.Logger.LogInformation("Updating project settings...");
544-
using (ctx.Logger.Indent())
583+
// Sync project settings — skipped for --agent-registration-only; the user's intent is purely
584+
// to register the agent, not to regenerate appsettings files.
585+
if (!skipIdentityAndPermissions)
545586
{
546-
// Pass ctx.Config directly so AgentDescription and AgentIdentityDisplayName
547-
// derived from --agent-name are written rather than stale values from disk.
548-
ctx.Results.ProjectSettingsWritten = await ProjectSettingsSyncHelper.ExecuteAsync(
549-
ctx.ConfigFile.FullName, ctx.Config,
550-
ctx.PlatformDetector, ctx.Logger);
587+
ctx.Logger.LogInformation("Updating project settings...");
588+
using (ctx.Logger.Indent())
589+
{
590+
ctx.Results.ProjectSettingsWritten = await ProjectSettingsSyncHelper.ExecuteAsync(
591+
ctx.ConfigFile.FullName, ctx.Config,
592+
ctx.PlatformDetector, ctx.Logger);
593+
}
551594
}
552595
}
553596

0 commit comments

Comments
 (0)