From cdef10b3e6df22caed0669ad56d9678a9e8b5dfc Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Thu, 25 Jun 2026 22:47:58 +0530 Subject: [PATCH 01/24] MHA azd cli --- .../extensions/azure.ai.agents/cspell.yaml | 6 + .../demo/agent-init-list-show.md | 170 +++ .../internal/cmd/agent_endpoint.go | 17 +- .../azure.ai.agents/internal/cmd/delete.go | 95 +- .../azure.ai.agents/internal/cmd/deploy.go | 57 + .../azure.ai.agents/internal/cmd/helpers.go | 17 +- .../azure.ai.agents/internal/cmd/init.go | 54 + .../cmd/init_from_templates_helpers.go | 65 + .../internal/cmd/init_managed.go | 464 +++++++ .../internal/cmd/init_managed_foundry.go | 367 +++++ .../azure.ai.agents/internal/cmd/invoke.go | 19 + .../internal/cmd/invoke_managed.go | 134 ++ .../internal/cmd/invoke_managed_test.go | 81 ++ .../azure.ai.agents/internal/cmd/list.go | 132 ++ .../azure.ai.agents/internal/cmd/listen.go | 58 +- .../internal/cmd/project_endpoint.go | 55 +- .../internal/cmd/project_endpoint_test.go | 58 + .../internal/cmd/prompt_service.go | 142 ++ .../azure.ai.agents/internal/cmd/root.go | 2 + .../azure.ai.agents/internal/cmd/show.go | 64 +- .../agents/agent_api/managed_operations.go | 661 +++++++++ .../agent_api/managed_operations_test.go | 357 +++++ .../internal/pkg/agents/agent_api/models.go | 46 + .../pkg/agents/agent_yaml/managed_test.go | 185 +++ .../internal/pkg/agents/agent_yaml/map.go | 50 +- .../internal/pkg/agents/agent_yaml/parse.go | 37 + .../internal/pkg/agents/agent_yaml/yaml.go | 29 + .../internal/project/config.go | 6 + .../internal/project/prompt_client.go | 339 +++++ .../internal/project/prompt_client_test.go | 371 +++++ .../internal/project/service_target_agent.go | 75 +- .../internal/project/service_target_prompt.go | 579 ++++++++ .../internal/project/workspace_create.go | 240 ++++ .../my-prompt-agent-1031-0625/.gitignore | 1 + .../my-prompt-agent-1031-0625/azure.yaml | 12 + .../infra/abbreviations.json | 137 ++ .../infra/core/ai/acr-role-assignment.bicep | 27 + .../infra/core/ai/ai-project.bicep | 417 ++++++ .../infra/core/ai/connection.bicep | 112 ++ .../infra/core/ai/existing-ai-project.bicep | 140 ++ .../infra/core/host/acr.bicep | 88 ++ .../applicationinsights-dashboard.bicep | 1236 +++++++++++++++++ .../core/monitor/applicationinsights.bicep | 47 + .../infra/core/monitor/loganalytics.bicep | 22 + .../infra/core/search/azure_ai_search.bicep | 211 +++ .../core/search/bing_custom_grounding.bicep | 84 ++ .../infra/core/search/bing_grounding.bicep | 83 ++ .../infra/core/storage/storage.bicep | 113 ++ .../infra/main.bicep | 248 ++++ .../infra/main.parameters.json | 78 ++ 50 files changed, 8065 insertions(+), 23 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/demo/agent-init-list-show.md create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/list.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_client.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/workspace_create.go create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/.gitignore create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/azure.yaml create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/abbreviations.json create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/acr-role-assignment.bicep create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/ai-project.bicep create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/connection.bicep create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/existing-ai-project.bicep create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/host/acr.bicep create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/applicationinsights-dashboard.bicep create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/applicationinsights.bicep create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/loganalytics.bicep create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/azure_ai_search.bicep create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/bing_custom_grounding.bicep create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/bing_grounding.bicep create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/storage/storage.bicep create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/main.bicep create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/main.parameters.json diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 2d5c10d89d6..5352749c50d 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -79,3 +79,9 @@ words: - parseable - azd's - deepseek + # Managed agent (Foundry PES / vienna harness) terms + - vienna + - azureml + - cognitiveservices + - fdp + - PES diff --git a/cli/azd/extensions/azure.ai.agents/demo/agent-init-list-show.md b/cli/azd/extensions/azure.ai.agents/demo/agent-init-list-show.md new file mode 100644 index 00000000000..9f3547a6003 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/demo/agent-init-list-show.md @@ -0,0 +1,170 @@ +# azd ai agent — Demo Script (init → list → show) + +A recording-ready script for a short screencast of the `azure.ai.agents` extension. +Each section has **[NARRATION]** (what to say) and **[RUN]** (what to type on screen). + +- Target time: ~3–4 minutes +- Shell: PowerShell +- Pre-req: `azd auth login` already done, an existing Foundry project available + +--- + +## 0. Setup (do this BEFORE recording — keep off-camera) + +```powershell +# Clean, empty folder for the demo +New-Item -ItemType Directory -Force -Path "$HOME\azd-agent-demo" | Out-Null +Set-Location "$HOME\azd-agent-demo" + +# Make output deterministic and clean for capture +$env:NO_COLOR = "1" # stable text, no ANSI escapes +$env:AZURE_CORE_OUTPUT = "none" + +# Confirm the extension is installed +azd extension list | Select-String "azure.ai.agents" +``` + +> Tip: Increase terminal font size and clear scrollback (`Clear-Host`) right before you hit record. + +--- + +## 1. Intro (10–15s) + +**[NARRATION]** +> "In this short demo I'll create a prompt-based AI agent with the Azure Developer CLI, +> then use the agent lifecycle commands to list it and inspect its status — +> all without leaving the terminal." + +**[RUN]** +```powershell +Clear-Host +azd version +``` + +--- + +## 2. `azd ai agent init` (60–90s) + +**[NARRATION]** +> "First, `azd ai agent init`. This scaffolds a new agent project: it walks me through +> picking a subscription and a Foundry project, selecting a model, and it writes an +> `azure.yaml`, an `agent.yaml` manifest, and the infrastructure to provision." + +**[RUN]** +```powershell +azd ai agent init +``` + +**On-screen choices to make (call these out as you click):** +1. Agent type → **Prompt agent** (managed) +2. Subscription → your demo subscription +3. Foundry project → **Use an existing Foundry project** → pick your project +4. Model deployment → e.g. **gpt-4.1-mini** +5. Agent name → **my-demo-agent** + +**[NARRATION] (while files generate)** +> "Notice it generated everything I need: the service definition, the agent manifest, +> and a Bicep template. Let me show the two key files." + +**[RUN]** +```powershell +Get-Content azure.yaml +Get-Content agent.yaml +``` + +**[NARRATION]** +> "The `agent.yaml` is the heart of the agent — its kind, model, and the instructions +> that define its behavior." + +--- + +## 3. Provision + deploy the agent (45–60s) + +**[NARRATION]** +> "Now I'll run `azd up`. This provisions any required resources and then creates the +> agent on the managed Foundry harness." + +**[RUN]** +```powershell +azd up +``` + +**[NARRATION] (when it finishes)** +> "Deployment succeeded. The agent is now live on my Foundry project. +> Let's use the lifecycle commands to confirm that." + +--- + +## 4. `azd ai agent list` (30–40s) + +**[NARRATION]** +> "`azd ai agent list` shows every agent on the project this environment is connected to, +> with its version and status." + +**[RUN]** +```powershell +azd ai agent list +``` + +**[NARRATION]** +> "There's `my-demo-agent`, version 1, status active. The same project can host multiple +> agents and they all show up here." + +--- + +## 5. `azd ai agent show` (40–60s) + +**[NARRATION]** +> "To inspect a single agent, I use `azd ai agent show`. By default it prints a concise +> status table." + +**[RUN]** +```powershell +azd ai agent show +``` + +**[NARRATION]** +> "Name, kind, version, status, and the harness endpoint. And because azd is built for +> automation, I can get the full object as JSON for scripting." + +**[RUN]** +```powershell +azd ai agent show --output json +``` + +**[NARRATION]** +> "Here's the complete agent definition — the model, the instructions, the managed +> identity, and the version metadata — exactly what you'd pipe into another tool." + +--- + +## 6. Wrap-up (10–15s) + +**[NARRATION]** +> "And that's the core loop: `init` to scaffold, `azd up` to deploy, then `list` and `show` +> to manage your agents — a complete, terminal-first workflow for Azure AI agents. +> Thanks for watching." + +**[RUN] (optional teardown, off-camera)** +```powershell +azd down --purge --force +``` + +--- + +## Quick command cheat-sheet (for the description / pinned comment) + +```text +azd ai agent init # scaffold a new agent project +azd up # provision + deploy the agent +azd ai agent list # list agents on the project +azd ai agent show # show status of the resolved agent (table) +azd ai agent show --output json # full agent object as JSON +``` + +## Recording tips + +- Set `NO_COLOR=1` so captured text stays clean and copy-pasteable. +- Run each block once **before** recording to warm caches (first run can be slower). +- If a command is long-running, plan a jump-cut at the "creating prompt agent" step. +- Keep the window at a fixed size so zoom/crop is consistent across takes. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go index c8b571d2f31..914effcd8f0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go @@ -72,7 +72,10 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { ) } - if !strings.EqualFold(u.Scheme, "https") { + bypass := foundryEndpointValidationBypassed() + + if !strings.EqualFold(u.Scheme, "https") && + !(bypass && strings.EqualFold(u.Scheme, "http")) { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, "--agent-endpoint must use https", @@ -81,7 +84,14 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { } host := strings.ToLower(u.Hostname()) - if host == "" || !isFoundryHost(host) { + if host == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint host must not be empty", + agentEndpointHint, + ) + } + if !bypass && !isFoundryHost(host) { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf("--agent-endpoint host %q is not a Foundry host (*%s)", u.Hostname(), agentEndpointHostHint), @@ -91,7 +101,8 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { // Reject explicit ports — Foundry endpoints always use the default HTTPS port, // and silently dropping a non-default port would route requests to a different origin. - if u.Port() != "" { + // The override path allows ports (e.g. http://localhost:5000) for local backends. + if !bypass && u.Port() != "" { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf("--agent-endpoint host %q must not include a port", u.Host), diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go index fd9950f1d42..74d3c709959 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go @@ -33,8 +33,8 @@ func newDeleteCommand(extCtx *azdext.ExtensionContext) *cobra.Command { cmd := &cobra.Command{ Use: "delete [name]", - Short: "Delete a hosted agent.", - Long: `Delete a hosted agent and all of its versions. + Short: "Delete an agent.", + Long: `Delete an agent and all of its versions. If --version is specified, only that version is deleted (the agent itself remains). @@ -99,6 +99,15 @@ func (a *DeleteAction) Run(ctx context.Context) error { } defer azdClient.Close() + // Prompt (kind=managed) agents are azd services on the harness. They are + // torn down with the rest of the project via `azd down`, so redirect + // rather than calling the Foundry agent-delete path that would fail. + if pctx, isPrompt, pErr := resolvePromptAgentService( + ctx, azdClient, a.flags.name, a.flags.noPrompt, + ); pErr == nil && isPrompt { + return a.runPromptDelete(ctx, azdClient, pctx) + } + info, err := resolveAgentServiceFromProject(ctx, azdClient, a.flags.name, a.flags.noPrompt) if err != nil { return err @@ -266,3 +275,85 @@ func classifyDeleteError(err error, agentName string) error { } return exterrors.ServiceFromAzure(err, exterrors.OpDeleteAgent) } + +// runPromptDelete deletes a prompt (kind=managed) agent from the harness. It +// is dispatched from Run() when the resolved azure.ai.agent service carries a +// promptAgent config block. The agent is removed from the harness directly; +// to tear down the whole project (infra included) use `azd down`. +// +// Versioning is not supported for prompt agents today — the backend does not +// expose a per-version delete on the v2.0 surface — so --version is rejected +// with a typed validation error rather than silently ignored. +func (a *DeleteAction) runPromptDelete( + ctx context.Context, + azdClient *azdext.AzdClient, + pctx *promptServiceContext, +) error { + if a.flags.version != "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--version is not supported for prompt agents", + "prompt agents do not expose per-version delete; omit --version to delete the agent", + ) + } + + agentName := pctx.AgentName() + if agentName == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentName, + "agent name is required but could not be resolved", + "set 'name' in agent.yaml or pass the agent name as a positional argument", + ) + } + + // Confirmation prompt (skip in --no-prompt mode). + if !a.flags.noPrompt { + message := fmt.Sprintf("Delete prompt agent %q from the harness?", agentName) + if a.flags.force { + message = fmt.Sprintf( + "Force-delete prompt agent %q? This will terminate all active sessions.", + agentName, + ) + } + defaultValue := false + resp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: message, + DefaultValue: &defaultValue, + }, + }) + if promptErr != nil { + if exterrors.IsCancellation(promptErr) { + return exterrors.Cancelled("delete cancelled") + } + return fmt.Errorf("prompting for confirmation: %w", promptErr) + } + if resp.Value == nil || !*resp.Value { + return exterrors.Cancelled("delete cancelled by user") + } + } + + client, err := pctx.newClient() + if err != nil { + return err + } + + result, err := client.DeleteAgent(ctx, agentName, pctx.Settings.EffectiveAPIVersion(), a.flags.force) + if err != nil { + return classifyDeleteError(err, agentName) + } + + switch a.flags.output { + case "json": + data, jsonErr := json.MarshalIndent(result, "", " ") + if jsonErr != nil { + return fmt.Errorf("failed to marshal response: %w", jsonErr) + } + fmt.Println(string(data)) + default: + fmt.Printf("Prompt agent %q deleted from the harness.\n", agentName) + fmt.Println("To also tear down the project infrastructure, run `azd down`.") + } + + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go new file mode 100644 index 00000000000..fcefec0e2a0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azureaiagent/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// newDeployCommand creates `azd ai agent deploy`, which now exists only to +// redirect users to the standard azd lifecycle. +// +// Prompt agents are first-class azd services (host: azure.ai.agent) created on +// the harness by the service-target provider during `azd up` / `azd deploy`, +// exactly like hosted agents. The previous standalone harness-deploy behavior +// has been removed in favor of that unified flow. +func newDeployCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + + cmd := &cobra.Command{ + Use: "deploy [name]", + Short: "Deprecated: use `azd up` or `azd deploy`.", + Hidden: true, + Long: `Deprecated. Prompt and hosted agents both deploy through the standard azd +lifecycle now. + +Run 'azd up' to provision infrastructure and create the agent, or 'azd deploy' +to (re)deploy the agent once infrastructure exists.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + return (&DeployAction{}).Run(ctx) + }, + } + + return cmd +} + +// DeployAction implements the deprecated deploy redirect. +type DeployAction struct{} + +func (a *DeployAction) Run(_ context.Context) error { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "`azd ai agent deploy` has been replaced by the standard azd lifecycle", + fmt.Sprintf( + "run %q to provision and deploy, or %q to (re)deploy an existing project", + "azd up", "azd deploy", + ), + ) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go index 60396e5baa8..432868d8240 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go @@ -546,6 +546,11 @@ type AgentServiceInfo struct { AgentName string // deployed agent name from env Version string // deployed agent version from env AgentEndpoint string // full AGENT_{SVC}_ENDPOINT URL (includes name + version) + // ServiceDir is the absolute path to the service's source directory + // (project.Path joined with svc.RelativePath). It points at the folder + // that contains the service's agent.yaml, when one was scaffolded by + // `azd ai agent init`. May be empty if the resolver could not compute it. + ServiceDir string } // promptForAgentService prompts the user to select one of multiple azure.ai.agent services. @@ -657,13 +662,23 @@ func resolveAgentService( // resolveAgentServiceFromProject finds the azure.ai.agent service in azure.yaml // and resolves its deployed agent name and version from the azd environment. func resolveAgentServiceFromProject(ctx context.Context, azdClient *azdext.AzdClient, name string, noPrompt bool) (*AgentServiceInfo, error) { - svc, _, err := resolveAgentService(ctx, azdClient, name, noPrompt) + svc, project, err := resolveAgentService(ctx, azdClient, name, noPrompt) if err != nil { return nil, err } info := &AgentServiceInfo{ServiceName: svc.Name} + // Best-effort: compute the on-disk service directory so callers can find + // the agent.yaml that backs the service. Errors here are intentionally + // not fatal — older azure.yaml entries (or services in unusual layouts) + // may not resolve cleanly, and the rest of the resolver remains useful. + if project != nil { + if dir, joinErr := paths.JoinAllowRoot(project.Path, svc.RelativePath); joinErr == nil { + info.ServiceDir = dir + } + } + // Resolve agent name and version from azd environment envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) if err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 35e28a61260..6d7f8b50d42 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -56,6 +56,7 @@ type initFlags struct { model string manifestPointer string agentName string + description string src string env string protocols []string @@ -69,6 +70,11 @@ type initFlags struct { // mirrors the `--force` convention used by `azd down`, `azd env remove`, // `azd config reset`, and `azd infra generate`. force bool + // kind, when set, explicitly selects the agent runtime ("hosted" or + // "managed") and bypasses the interactive kind prompt. This is primarily + // for non-interactive callers (--no-prompt) and automation; interactive + // users get the kind prompt when this is empty. + kind string // noPrompt is resolved from the extension context (--no-prompt / AZD_NO_PROMPT) // and is not registered as a CLI flag on the init command itself. noPrompt bool @@ -932,6 +938,46 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, Timeout: 30 * time.Second, } + // Ask the user which agent kind to initialize, before any + // hosted-specific manifest/template detection runs. When the user + // has already passed a manifest, --src, or any other hosted-only + // signal we skip the prompt and stay on the hosted path; only an + // otherwise-blank invocation can branch into the prompt-agent flow. + // + // An explicit --kind flag always wins: it bypasses both the prompt + // and the hosted-signal gating so automation can select the + // prompt-agent runtime non-interactively. "managed" is accepted as + // a backward-compatible alias for "prompt". + if flags.kind != "" { + switch agentKindChoice(strings.ToLower(strings.TrimSpace(flags.kind))) { + case AgentKindChoicePrompt, AgentKindChoiceManaged: + return runInitManaged(ctx, flags, azdClient) + case AgentKindChoiceHosted: + // Fall through to the hosted flow below. + default: + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("unknown --kind value %q", flags.kind), + "supported values are: hosted, prompt", + ) + } + } else { + hostedSignalsPresent := userProvidedManifest || + flags.src != "" || + flags.deployMode != "" || + flags.runtime != "" || + flags.entryPoint != "" + if !hostedSignalsPresent { + kindChoice, kindErr := promptAgentKind(ctx, azdClient, flags.noPrompt) + if kindErr != nil { + return kindErr + } + if kindChoice == AgentKindChoicePrompt || kindChoice == AgentKindChoiceManaged { + return runInitManaged(ctx, flags, azdClient) + } + } + } + // Track whether a project already exists so the cd hint is // only shown for brand-new top-level project folders, not // when a template adds a subfolder to an existing project. @@ -1291,6 +1337,9 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, cmd.Flags().StringVar(&flags.agentName, "agent-name", "", "Foundry agent name to write to agent.yaml. Reusing a name creates a new version of the existing agent.") + cmd.Flags().StringVar(&flags.description, "description", "", + "Description to write to agent.yaml. Used as the agent's human-readable summary.") + cmd.Flags().StringVarP(&flags.src, "src", "s", "", "Directory to download the agent definition to (defaults to 'src/')") @@ -1313,6 +1362,11 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Overwrite an input manifest that already lives inside the generated src tree without prompting. "+ "Required together with --no-prompt when init would otherwise need confirmation.") + cmd.Flags().StringVar(&flags.kind, "kind", "", + "Agent runtime to initialize: 'hosted' (bring your own code/container) or 'prompt' "+ + "(model + instructions; Foundry runs Brain+Hand, Harness: GHCP). When omitted, you are "+ + "prompted interactively.") + return cmd } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go index 5ca25256dea..e5a98ca7962 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go @@ -85,6 +85,71 @@ const ( initModeTemplate = "template" ) +// agentKindChoice represents the discriminator the user picks at the very +// start of `azd ai agent init`. It selects between the two supported agent +// runtimes: hosted (today's container/code-deploy flow) and prompt (the +// Foundry Brain+Hand harness, currently powered by GitHub Copilot / GHCP). +type agentKindChoice string + +const ( + // AgentKindChoiceHosted is the existing hosted-agent path — the customer + // supplies code or a container image and the platform runs it on Azure + // Container Apps. + AgentKindChoiceHosted agentKindChoice = "hosted" + // AgentKindChoicePrompt is the "prompt" agent path — the customer declares + // model + instructions and the Foundry harness (GHCP) runs Brain+Hand on + // demand. Note: the on-the-wire agent kind for this path is still + // "managed" (see agent_yaml.AgentKindManaged); "prompt" is the + // user-facing choice value only. + AgentKindChoicePrompt agentKindChoice = "prompt" + // AgentKindChoiceManaged is a backward-compatible alias for + // AgentKindChoicePrompt accepted on the --kind flag. Prefer "prompt". + AgentKindChoiceManaged agentKindChoice = "managed" +) + +// promptAgentKind asks the user which agent kind to initialize. In no-prompt +// mode it returns AgentKindChoiceHosted to preserve today's behaviour for CI +// callers that do not yet know about the new kind. The selection is the very +// first interactive prompt in `azd ai agent init` and routes the rest of the +// init flow. +func promptAgentKind( + ctx context.Context, + azdClient *azdext.AzdClient, + noPrompt bool, +) (agentKindChoice, error) { + if noPrompt { + return AgentKindChoiceHosted, nil + } + + choices := []*azdext.SelectChoice{ + { + Label: "Hosted agent — bring your own code or container (deployed to Azure Container Apps)", + Value: string(AgentKindChoiceHosted), + }, + { + Label: "Prompt agent — model + instructions only (Foundry runs Brain+Hand; Harness: GHCP)", + Value: string(AgentKindChoicePrompt), + }, + } + defaultIndex := int32(0) + + resp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "What kind of agent do you want to initialize?", + Choices: choices, + SelectedIndex: &defaultIndex, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", exterrors.Cancelled("agent kind selection was cancelled") + } + return "", fmt.Errorf("failed to prompt for agent kind: %w", err) + } + + return agentKindChoice(choices[*resp.Value].Value), nil +} + // promptInitMode asks the user whether to use existing code or start from a template. // If the current directory is empty, automatically returns initModeTemplate. // In no-prompt mode with existing local files, defaults to using the current directory. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go new file mode 100644 index 00000000000..64b76a37797 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go @@ -0,0 +1,464 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/fatih/color" + "go.yaml.in/yaml/v3" +) + +// runInitManaged is the entry point for `azd ai agent init` when the user has +// selected the "prompt" (kind=managed) agent kind. It produces a first-class +// azd project so prompt agents follow the same `azd up` / `azd deploy` +// lifecycle as hosted agents: +// +// 1. Scaffolds (or reuses) an azd project + infra via ensureProject — the +// same azd-ai-starter-basic template the hosted flow uses. +// 2. Writes an agent.yaml (kind=managed) into the service directory. +// 3. Adds an azure.yaml service entry (Host=azure.ai.agent) whose config +// carries the harness connection details in a promptAgent block. +// +// The harness create/invoke/delete then happen through the service-target +// provider during `azd deploy` / `azd up`, exactly like hosted agents — no +// bespoke standalone deploy command or sidecar config file. +func runInitManaged( + ctx context.Context, + flags *initFlags, + azdClient *azdext.AzdClient, +) error { + // Prompt for the conceptual agent details first: name and description. + agentName, err := promptManagedAgentName(ctx, azdClient, flags) + if err != nil { + return err + } + + description, err := promptManagedAgentDescription(ctx, azdClient, flags) + if err != nil { + return err + } + + // The harness base URL is where the agent runtime lives (env-overridable). + // Independently of that, the prompt-agent init experience mirrors hosted: + // in interactive mode we always walk subscription -> Foundry project -> + // model so the workspace tuple and model endpoint come from a real project. + // --no-prompt skips the interactive Azure resolution and uses flags/env. + settings := project.DefaultPromptAgentSettings() + if envBaseURL := strings.TrimSpace(os.Getenv(project.PromptBaseURLEnvVar)); envBaseURL != "" { + settings.BaseURL = envBaseURL + } + useGuidedFoundry := !flags.noPrompt + + // Decide where the project lives and where the agent.yaml goes within it. + // When an azd project already exists in the cwd we add the agent as a new + // service in a subfolder; otherwise we scaffold a brand-new project folder + // named after the agent and place agent.yaml at its root. + existingProject := fileExists("azure.yaml") + folderName := sanitizeAgentName(agentName) + if folderName == "" || folderName == "." || folderName == ".." || strings.ContainsAny(folderName, `/\`) { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("cannot derive a safe folder name from agent name %q", agentName), + "choose an agent name that contains alphanumerics or hyphens", + ) + } + + var projectTargetDir, serviceRelPath string + if existingProject { + projectTargetDir = "." + serviceRelPath = folderName + } else { + projectTargetDir = folderName + serviceRelPath = "." + } + + // Scaffold or locate the azd project + infra. On a fresh scaffold this + // downloads the starter template and chdirs into the new project folder. + if _, err := ensureProject(ctx, flags, azdClient, projectTargetDir); err != nil { + return err + } + + // Ensure an azd environment exists so `azd up`/`azd deploy` (and the + // guided Azure resolution below) have one to read/write. + env := getExistingEnvironment(ctx, flags.env, azdClient) + if env == nil { + env, err = createNewEnvironment(ctx, azdClient, flags.env) + if err != nil { + return err + } + } + + // Resolve the model deployment. The guided path walks subscription -> + // Foundry project -> model (version/SKU/capacity/name) and returns a full + // deployment to provision and reference; otherwise we use the curated/custom + // model prompt (or --model in --no-prompt mode). + var ( + model string + deployment *project.Deployment + ) + if useGuidedFoundry { + deployment, err = resolvePromptHarnessTarget(ctx, azdClient, flags, env, &settings) + if err != nil { + return err + } + if deployment != nil { + model = deployment.Name + } + } + if strings.TrimSpace(model) == "" { + model, err = promptManagedAgentModel(ctx, azdClient, flags) + if err != nil { + return err + } + } + + instructions, err := promptManagedAgentInstructions(ctx, azdClient, flags) + if err != nil { + return err + } + + // cwd is now the project root. Create the service directory when nested. + if serviceRelPath != "." { + if err := os.MkdirAll(serviceRelPath, osutil.PermissionDirectory); err != nil { + return fmt.Errorf("creating service folder %q: %w", serviceRelPath, err) + } + } + + managedAgent := agent_yaml.ManagedAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Name: agentName, + Kind: agent_yaml.AgentKindManaged, + }, + Model: model, + Instructions: instructions, + } + if strings.TrimSpace(description) != "" { + desc := strings.TrimSpace(description) + managedAgent.AgentDefinition.Description = &desc + } + if err := writeManagedAgentYAML(serviceRelPath, &managedAgent); err != nil { + return err + } + + if err := addPromptAgentService(ctx, azdClient, agentName, serviceRelPath, &settings, deployment); err != nil { + return err + } + + // Persist the deployment name (matching hosted) so other commands can + // resolve the model deployment from the azd environment. + if deployment != nil { + if err := setEnvValue(ctx, azdClient, env.Name, "AZURE_AI_MODEL_DEPLOYMENT_NAME", deployment.Name); err != nil { + return err + } + } + + printManagedInitSummary(agentName, model, serviceRelPath, projectTargetDir, existingProject, &settings) + return nil +} + +// addPromptAgentService registers the prompt agent as an azure.yaml service +// entry with Host=azure.ai.agent and a promptAgent config block. Unlike hosted +// agents there is no Docker/Language — the harness owns the runtime. When a +// resolved model deployment is supplied it is recorded under the service config +// so `azd provision` creates it (via AI_PROJECT_DEPLOYMENTS), mirroring hosted. +func addPromptAgentService( + ctx context.Context, + azdClient *azdext.AzdClient, + agentName, serviceRelPath string, + settings *project.PromptAgentSettings, + deployment *project.Deployment, +) error { + agentConfig := project.ServiceTargetAgentConfig{ + PromptAgent: settings, + } + if deployment != nil { + agentConfig.Deployments = []project.Deployment{*deployment} + } + configStruct, err := project.MarshalStruct(&agentConfig) + if err != nil { + return fmt.Errorf("marshaling prompt agent service config: %w", err) + } + + req := &azdext.AddServiceRequest{ + Service: &azdext.ServiceConfig{ + Name: agentName, + RelativePath: serviceRelPath, + Host: AiAgentHost, + Config: configStruct, + }, + } + if _, err := azdClient.Project().AddService(ctx, req); err != nil { + return fmt.Errorf("adding prompt agent service to project: %w", err) + } + return nil +} + +// promptManagedAgentName asks for the agent's name. The name is the Foundry +// agent identity and (for a fresh project) the project folder name. It matches +// the hosted flow's message, help text, and validation so the two flows feel +// the same. +func promptManagedAgentName( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, +) (string, error) { + if strings.TrimSpace(flags.agentName) != "" { + return validateInitAgentName(flags.agentName) + } + if flags.noPrompt { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-name is required in non-interactive mode for prompt agents", + "pass --agent-name on the command line", + ) + } + + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter a name for your agent", + DefaultValue: "my-prompt-agent", + HelpMessage: "Foundry agents are unique by name within a project. " + + "Reusing a name creates a new version of the existing agent.", + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", exterrors.Cancelled("agent name prompt was cancelled") + } + return "", fmt.Errorf("prompting for agent name: %w", err) + } + name := strings.TrimSpace(resp.Value) + if name == "" { + name = "my-prompt-agent" + } + return validateInitAgentName(name) +} + +// promptManagedAgentDescription asks for an optional human-readable +// description, mirroring the hosted flow. Blank is allowed. In --no-prompt +// mode the --description flag value (or empty) is used. +func promptManagedAgentDescription( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, +) (string, error) { + if strings.TrimSpace(flags.description) != "" { + return strings.TrimSpace(flags.description), nil + } + if flags.noPrompt { + return "", nil + } + + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter a description for your agent (optional)", + DefaultValue: "", + Required: false, + IgnoreHintKeys: true, + HelpMessage: "A short summary of what this agent does. Written to agent.yaml and shown in Foundry.", + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", exterrors.Cancelled("description prompt was cancelled") + } + return "", fmt.Errorf("prompting for description: %w", err) + } + return strings.TrimSpace(resp.Value), nil +} + +// promptManagedAgentModelChoices is the curated list of common Foundry chat +// models offered in the guided model prompt. The first entry is the default +// selection. A final "custom" option lets the user enter any deployment name. +var promptManagedAgentModelChoices = []string{ + "gpt-4.1-mini", + "gpt-4.1", + "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-mini", + "o4-mini", +} + +// promptManagedAgentModel asks which model deployment the agent should call. +// Unlike a bare text field, it offers a curated list of common models plus a +// "custom" escape hatch — a guided experience closer to the hosted model +// selection. The --model flag (or --no-prompt) bypasses the prompt. +func promptManagedAgentModel( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, +) (string, error) { + if strings.TrimSpace(flags.model) != "" { + return strings.TrimSpace(flags.model), nil + } + if flags.noPrompt { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "--model is required in non-interactive mode for prompt agents", + "pass --model on the command line", + ) + } + + const customLabel = "Enter a custom model deployment name" + choices := make([]*azdext.SelectChoice, 0, len(promptManagedAgentModelChoices)+1) + for _, m := range promptManagedAgentModelChoices { + choices = append(choices, &azdext.SelectChoice{Label: m, Value: m}) + } + choices = append(choices, &azdext.SelectChoice{Label: customLabel, Value: customLabel}) + + defaultIndex := int32(0) + selectResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select the model deployment your agent will call", + Choices: choices, + SelectedIndex: &defaultIndex, + HelpMessage: "The name of a model deployment in your Foundry project. " + + "Provision it with `azd up`, or pick an existing deployment name.", + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", exterrors.Cancelled("model selection was cancelled") + } + return "", fmt.Errorf("prompting for model: %w", err) + } + + selected := choices[*selectResp.Value].Value + if selected != customLabel { + return selected, nil + } + + // Custom path: free-text deployment name. + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter the model deployment name", + DefaultValue: "gpt-4.1-mini", + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", exterrors.Cancelled("model selection was cancelled") + } + return "", fmt.Errorf("prompting for model: %w", err) + } + model := strings.TrimSpace(resp.Value) + if model == "" { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "model must not be empty", + "provide a non-empty model deployment name", + ) + } + return model, nil +} + +// promptManagedAgentInstructions asks for the agent's system instructions. +// In no-prompt mode it returns a stub the user can edit later. +func promptManagedAgentInstructions( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, +) (string, error) { + if flags.noPrompt { + return "You are a helpful AI assistant. Replace these instructions before deploying.", nil + } + + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter system instructions for your agent", + DefaultValue: "You are a helpful AI assistant.", + HelpMessage: "The system/developer message inserted into the model context before every turn.", + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", exterrors.Cancelled("instructions input was cancelled") + } + return "", fmt.Errorf("prompting for instructions: %w", err) + } + instructions := strings.TrimSpace(resp.Value) + if instructions == "" { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "instructions must not be empty", + "provide non-empty system instructions for the agent", + ) + } + return instructions, nil +} + +// writeManagedAgentYAML serializes the ManagedAgent and writes it to +// /agent.yaml. A schema annotation comment is prepended for editor +// validation parity with the hosted agent flow. +func writeManagedAgentYAML(targetDir string, managedAgent *agent_yaml.ManagedAgent) error { + content, err := yaml.Marshal(managedAgent) + if err != nil { + return fmt.Errorf("marshaling managed agent to YAML: %w", err) + } + + annotation := "# yaml-language-server: " + + "$schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ManagedAgent.yaml" + buf := bytes.NewBufferString(annotation + "\n\n") + if _, err := buf.Write(content); err != nil { + return fmt.Errorf("preparing agent.yaml file contents: %w", err) + } + + filePath := filepath.Join(targetDir, "agent.yaml") + if err := os.WriteFile(filePath, buf.Bytes(), osutil.PermissionFile); err != nil { + return fmt.Errorf("saving file to %s: %w", filePath, err) + } + log.Printf("Wrote managed agent.yaml at %s", filePath) + return nil +} + +// printManagedInitSummary prints a concise summary plus next-step hint. +func printManagedInitSummary( + agentName, model, serviceRelPath, projectTargetDir string, + existingProject bool, + settings *project.PromptAgentSettings, +) { + color.Green("\nInitialized prompt agent %q.", agentName) + + agentFile := "agent.yaml" + if serviceRelPath != "." { + agentFile = filepath.ToSlash(filepath.Join(serviceRelPath, "agent.yaml")) + } + fmt.Printf(" Agent file: %s\n", agentFile) + fmt.Printf(" Model: %s\n", model) + fmt.Printf(" Service entry: added to azure.yaml (host: %s)\n", AiAgentHost) + fmt.Printf(" Harness URL: %s\n", settings.BaseURL) + // Surface the resolved Foundry target when it isn't the local-dev default + // (i.e. the guided subscription -> project -> model path ran). + if settings.Workspace != project.DefaultPromptWorkspace { + fmt.Printf(" Workspace: %s\n", settings.Workspace) + } + if settings.ModelEndpoint != "" && settings.ModelEndpoint != project.DefaultPromptModelEndpoint { + fmt.Printf(" Model endpoint: %s\n", settings.ModelEndpoint) + } + + fmt.Println() + fmt.Println("Next steps:") + if !existingProject && projectTargetDir != "." { + fmt.Printf(" cd %q\n", projectTargetDir) + } + fmt.Println(" # Provision infrastructure and deploy the agent") + fmt.Println(" azd up") + fmt.Println(" # Or, once provisioned, just (re)deploy the agent") + fmt.Println(" azd deploy") + fmt.Println(" # Invoke it") + fmt.Println(" azd ai agent invoke \"hello\"") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go new file mode 100644 index 00000000000..f61233e1110 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go @@ -0,0 +1,367 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "log" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/project" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/output" +) + +// resolvePromptHarnessTarget drives the guided Foundry resolution for a prompt +// agent, mirroring the hosted agent experience: subscription -> Foundry project +// (select existing or create new) -> model deployment (version, SKU, capacity, +// name). It populates the harness workspace tuple and model endpoint on +// settings from the selected/created project, and returns the resolved model +// deployment to persist to azure.yaml. +// +// Location is NOT prompted separately: for an existing project it is derived +// from the project; for a new project it is prompted only at that point — the +// same architecture hosted agents rely on. +func resolvePromptHarnessTarget( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, + env *azdext.Environment, + settings *project.PromptAgentSettings, +) (*project.Deployment, error) { + azureContext, err := loadAzureContext(ctx, azdClient, env.Name) + if err != nil { + return nil, err + } + + // Subscription only — location is resolved per project branch below. + cred, err := ensureSubscription( + ctx, azdClient, azureContext, env.Name, + "Select an Azure subscription to find your Foundry project and models.", + ) + if err != nil { + return nil, err + } + + proj, err := selectPromptFoundryProject( + ctx, azdClient, cred, azureContext, env.Name, flags.projectResourceId, + ) + if err != nil { + return nil, err + } + + if proj == nil { + // Create-new path. Prompt for a location (a new project needs one) and + // signal Bicep to create the project + a model deployment. + fmt.Println(output.WithGrayFormat( + "No existing Foundry project selected. `azd up` will provision one " + + "with the model deployment you choose next.", + )) + if err := ensureLocation(ctx, azdClient, azureContext, env.Name); err != nil { + return nil, err + } + if err := setEnvValue(ctx, azdClient, env.Name, "USE_EXISTING_AI_PROJECT", "false"); err != nil { + return nil, err + } + if err := updatePendingProjectSignal(ctx, azdClient, env.Name, false); err != nil { + log.Printf("warning: failed to update project provision signal: %v", err) + } + // A new project is provisioned by `azd up`; the harness workspace tuple + // is filled from the provisioned env values at deploy time (overlay). + return resolvePromptModelDeployment(ctx, azdClient, azureContext, env, flags) + } + + // Existing project: populate the harness target and derive the location + // from the project (no location prompt). + settings.SubscriptionID = proj.SubscriptionId + settings.ResourceGroup = proj.ResourceGroupName + settings.Workspace = proj.ProjectName + settings.ModelEndpoint = fmt.Sprintf("https://%s.services.ai.azure.com", proj.AccountName) + // Record the Foundry project data-plane endpoint so all managed agent + // operations route to https://.services.ai.azure.com/api/projects//agents. + settings.ProjectEndpoint = fmt.Sprintf( + "https://%s.services.ai.azure.com/api/projects/%s", proj.AccountName, proj.ProjectName, + ) + settings.APIVersion = project.ProjectEndpointAPIVersion + + azureContext.Scope.Location = proj.Location + if proj.Location != "" { + if err := setEnvValue(ctx, azdClient, env.Name, "AZURE_AI_DEPLOYMENTS_LOCATION", proj.Location); err != nil { + return nil, err + } + } + + if err := setPromptFoundryProjectEnv(ctx, azdClient, env.Name, proj); err != nil { + return nil, err + } + if err := setEnvValue(ctx, azdClient, env.Name, "USE_EXISTING_AI_PROJECT", "true"); err != nil { + return nil, err + } + if err := updatePendingProjectSignal(ctx, azdClient, env.Name, true); err != nil { + log.Printf("warning: failed to update project provision signal: %v", err) + } + + return resolvePromptModelForExistingProject(ctx, azdClient, cred, azureContext, env, flags, proj) +} + +// selectPromptFoundryProject lists the Foundry projects in the subscription and +// prompts the user to pick one (or to create a new one). When projectResourceId +// is set it resolves that project directly without prompting. Returns nil when +// the user chose "Create a new Foundry project" or none were found. +// +// Unlike the hosted selectFoundryProject this does NOT filter by region or +// configure ACR/AppInsights connections, which are irrelevant to prompt agents. +func selectPromptFoundryProject( + ctx context.Context, + azdClient *azdext.AzdClient, + credential azcore.TokenCredential, + azureContext *azdext.AzureContext, + envName string, + projectResourceId string, +) (*FoundryProjectInfo, error) { + subscriptionId := azureContext.Scope.SubscriptionId + if strings.TrimSpace(projectResourceId) != "" { + return getFoundryProject(ctx, credential, subscriptionId, projectResourceId) + } + + projects, err := listFoundryProjects(ctx, credential, subscriptionId) + if err != nil { + return nil, fmt.Errorf("failed to list Foundry projects: %w", err) + } + if len(projects) == 0 { + return nil, nil + } + + choices := make([]*azdext.SelectChoice, 0, len(projects)+1) + for i, p := range projects { + label := fmt.Sprintf("%s / %s", p.AccountName, p.ProjectName) + if p.Location != "" { + label = fmt.Sprintf("%s (%s)", label, p.Location) + } + choices = append(choices, &azdext.SelectChoice{ + Label: label, + Value: fmt.Sprintf("%d", i), + }) + } + const createNewValue = "__create_new__" + choices = append(choices, &azdext.SelectChoice{ + Label: "Create a new Foundry project (provisioned by `azd up`)", + Value: createNewValue, + }) + + resp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a Foundry project to host your agent and model", + Choices: choices, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("project selection was cancelled") + } + return nil, exterrors.Dependency( + exterrors.CodeMissingAiProjectId, + fmt.Sprintf("failed to select a Foundry project: %s", err), + "pass --project-id to skip interactive project selection", + ) + } + + idx := int(*resp.Value) + if idx < 0 || idx >= len(projects) { + // "Create a new Foundry project" + return nil, nil + } + selected := projects[idx] + return &selected, nil +} + +// setPromptFoundryProjectEnv persists the core Foundry project identifiers to +// the azd environment so provisioning and deploy can resolve the project. This +// is the prompt-agent subset of configureFoundryProjectEnv (no connection +// discovery). +func setPromptFoundryProjectEnv( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + proj *FoundryProjectInfo, +) error { + resourceId := proj.ResourceId + if resourceId == "" { + resourceId = fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.CognitiveServices/accounts/%s/projects/%s", + proj.SubscriptionId, proj.ResourceGroupName, proj.AccountName, proj.ProjectName, + ) + } + foundryEndpoint := fmt.Sprintf( + "https://%s.services.ai.azure.com/api/projects/%s", proj.AccountName, proj.ProjectName, + ) + values := map[string]string{ + "AZURE_AI_PROJECT_ID": resourceId, + "AZURE_RESOURCE_GROUP": proj.ResourceGroupName, + "AZURE_AI_ACCOUNT_NAME": proj.AccountName, + "AZURE_AI_PROJECT_NAME": proj.ProjectName, + "FOUNDRY_PROJECT_ENDPOINT": foundryEndpoint, + } + for k, v := range values { + if err := setEnvValue(ctx, azdClient, envName, k, v); err != nil { + return err + } + } + return nil +} + +// resolvePromptModelForExistingProject resolves a model deployment for a prompt +// agent on an already-selected Foundry project. It offers the project's +// existing deployments first (reuse a live deployment), plus a "deploy a new +// model" option that runs the full catalog -> version -> SKU -> capacity flow. +func resolvePromptModelForExistingProject( + ctx context.Context, + azdClient *azdext.AzdClient, + credential azcore.TokenCredential, + azureContext *azdext.AzureContext, + env *azdext.Environment, + flags *initFlags, + proj *FoundryProjectInfo, +) (*project.Deployment, error) { + // --model short-circuits to the new-deployment configuration so the named + // model is resolved (version/SKU/capacity) and provisioned. + if strings.TrimSpace(flags.model) == "" { + deployments, err := listProjectDeployments( + ctx, credential, proj.SubscriptionId, proj.ResourceGroupName, proj.AccountName, + ) + if err != nil { + fmt.Println(output.WithWarningFormat( + "Could not list existing model deployments: %s. Choosing from the catalog instead.\n", err, + )) + } else if len(deployments) > 0 && !flags.noPrompt { + const newModelValue = "__new_model__" + choices := make([]*azdext.SelectChoice, 0, len(deployments)+1) + byName := make(map[string]*FoundryDeploymentInfo, len(deployments)) + for i := range deployments { + d := &deployments[i] + byName[d.Name] = d + label := d.Name + if d.ModelName != "" { + label = fmt.Sprintf("%s (%s", d.Name, d.ModelName) + if d.Version != "" { + label += " " + d.Version + } + label += ")" + } + choices = append(choices, &azdext.SelectChoice{Label: label, Value: d.Name}) + } + choices = append(choices, &azdext.SelectChoice{ + Label: "Deploy a new model from the catalog", + Value: newModelValue, + }) + + defaultIndex := int32(0) + resp, selErr := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select the model deployment your agent will call", + Choices: choices, + SelectedIndex: &defaultIndex, + }, + }) + if selErr != nil { + if exterrors.IsCancellation(selErr) { + return nil, exterrors.Cancelled("model selection was cancelled") + } + return nil, fmt.Errorf("prompting for model deployment: %w", selErr) + } + if selected := choices[*resp.Value].Value; selected != newModelValue { + d := byName[selected] + return &project.Deployment{ + Name: d.Name, + Model: project.DeploymentModel{ + Name: d.ModelName, + Format: d.ModelFormat, + Version: d.Version, + }, + Sku: project.DeploymentSku{ + Name: d.SkuName, + Capacity: d.SkuCapacity, + }, + }, nil + } + } + } + + return resolvePromptModelDeployment(ctx, azdClient, azureContext, env, flags) +} + +// resolvePromptModelDeployment runs the full "deploy a new model" flow — model +// selection from the catalog, then version / SKU / capacity via the shared +// modelSelector, then a deployment-name prompt — and returns the resulting +// deployment. It reuses the exact hosted helpers so prompt agents get the same +// deployment configuration UX. +func resolvePromptModelDeployment( + ctx context.Context, + azdClient *azdext.AzdClient, + azureContext *azdext.AzureContext, + env *azdext.Environment, + flags *initFlags, +) (*project.Deployment, error) { + selector := &modelSelector{ + azdClient: azdClient, + azureContext: azureContext, + environment: env, + flags: flags, + } + + defaultModel := strings.TrimSpace(flags.model) + if defaultModel == "" { + defaultModel = "gpt-4.1-mini" + } + + // getModelDetails handles model confirm/change, location-availability and + // quota retries, and the version / SKU / capacity selection (via + // PromptAiDeployment). allowSkip=false: a prompt agent must have a model. + modelDetails, err := selector.getModelDetails(ctx, defaultModel, false) + if err != nil { + return nil, err + } + + // Deployment name (defaults to the model name), matching hosted. + deploymentName := modelDetails.ModelName + if !flags.noPrompt { + resp, promptErr := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: fmt.Sprintf( + "Enter model deployment name for model '%s' (defaults to model name)", + modelDetails.ModelName, + ), + IgnoreHintKeys: true, + DefaultValue: modelDetails.ModelName, + }, + }) + if promptErr != nil { + if exterrors.IsCancellation(promptErr) { + return nil, exterrors.Cancelled("deployment name prompt was cancelled") + } + return nil, fmt.Errorf("prompting for deployment name: %w", promptErr) + } + if v := strings.TrimSpace(resp.Value); v != "" { + deploymentName = v + } + } + + deployment := &project.Deployment{ + Name: deploymentName, + Model: project.DeploymentModel{ + Name: modelDetails.ModelName, + Format: modelDetails.Format, + Version: modelDetails.Version, + }, + Sku: project.DeploymentSku{ + Name: modelDetails.Sku.Name, + Capacity: int(modelDetails.Capacity), + }, + } + return deployment, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go index 350ce18846f..9660a434373 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -355,6 +355,25 @@ func validateAgentEndpointFlags(cmd *cobra.Command, flags *invokeFlags) error { } func (a *InvokeAction) Run(ctx context.Context) error { + // Prompt (kind=managed) agents use a workspace-rooted Responses API on the + // harness. When the resolved azure.ai.agent service is a prompt agent we + // route there before the hosted protocol resolution — unless the user + // explicitly targeted a local server (--local) or a full deployed agent + // endpoint (--agent-endpoint), in which case we honor that intent. + if a.endpoint == nil && !a.flags.local { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + pctx, isPrompt, pErr := resolvePromptAgentService(ctx, azdClient, a.flags.name, a.noPrompt) + azdClient.Close() + if pErr == nil && isPrompt { + return a.runPromptInvoke(ctx, pctx) + } + // pErr (e.g. no azure.yaml) is non-fatal here: fall through to the + // existing hosted/local resolution which surfaces its own errors. + } + protocol, err := a.resolveProtocol(ctx) if err != nil { return err diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go new file mode 100644 index 00000000000..9678cfa5257 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "azureaiagent/internal/exterrors" +) + +// managedAgentReference is the body fragment that binds a Responses call to a +// specific managed agent. It mirrors the shape the vienna harness expects +// (see test-e2e-foundry-tools.sh): `agent_reference: {type, name}`. +type managedAgentReference struct { + Type string `json:"type"` + Name string `json:"name"` +} + +// managedResponsesRequest is the OpenAI-shape Responses request body sent to +// the workspace-rooted /openai/responses endpoint for a managed agent. +type managedResponsesRequest struct { + Model string `json:"model"` + Input string `json:"input"` + Stream bool `json:"stream"` + AgentReference managedAgentReference `json:"agent_reference"` + Tools []any `json:"tools"` +} + +// runPromptInvoke sends a message to a prompt (kind=managed) agent via the +// harness Responses API and streams the assistant's reply to stdout. +// +// The target harness and agent identity come from the resolved azure.yaml +// service (promptServiceContext), so prompt agents invoke through the same +// service resolution as hosted agents. +func (a *InvokeAction) runPromptInvoke(ctx context.Context, pctx *promptServiceContext) error { + agentName := a.flags.name + if agentName == "" { + agentName = pctx.AgentName() + } + if strings.TrimSpace(agentName) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentName, + "agent name could not be resolved", + "set 'name' in agent.yaml or pass the agent name as the first argument", + ) + } + + body, _, err := a.resolveBody() + if err != nil { + return err + } + + payload, err := json.Marshal(managedResponsesRequest{ + Model: pctx.Agent.Model, + Input: string(body), + Stream: true, + AgentReference: managedAgentReference{Type: "agent_reference", Name: agentName}, + Tools: []any{}, + }) + if err != nil { + return fmt.Errorf("building prompt invoke request: %w", err) + } + + client, err := pctx.newClient() + if err != nil { + return err + } + + headers := map[string]string{ + // The harness forwards model calls to this gateway. Required by the + // V3 harness engine (see test-e2e-foundry-tools.sh). + "x-model-endpoint": pctx.Settings.EffectiveModelEndpoint(), + } + + stream, _, err := client.CreateResponseStream(ctx, payload, headers) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateAgent) + } + defer stream.Close() + + if err := streamManagedSSE(stream, os.Stdout); err != nil { + return fmt.Errorf("reading prompt agent response stream: %w", err) + } + return nil +} + +// streamManagedSSE scans a Server-Sent Events stream from the harness Responses +// API and writes the assistant's text to w as it arrives. +// +// Only `response.output_text.delta` events produce visible output; lifecycle +// events (`response.created`, `response.completed`, etc.) are consumed +// silently. A trailing newline is emitted after the stream ends so the shell +// prompt returns on its own line. +func streamManagedSSE(r io.Reader, w io.Writer) error { + scanner := bufio.NewScanner(r) + // SSE data lines can be large (full JSON payloads); raise the buffer cap + // well above the 64 KiB default so a single event never overflows it. + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + + var event string + wroteText := false + for scanner.Scan() { + line := scanner.Text() + switch { + case strings.HasPrefix(line, "event:"): + event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:"): + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if event == "response.output_text.delta" { + var payload struct { + Delta string `json:"delta"` + } + if err := json.Unmarshal([]byte(data), &payload); err == nil && payload.Delta != "" { + fmt.Fprint(w, payload.Delta) + wroteText = true + } + } + case line == "": + // Blank line terminates an SSE event block. + event = "" + } + } + if wroteText { + fmt.Fprintln(w) + } + return scanner.Err() +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_test.go new file mode 100644 index 00000000000..f50b9d4e1ca --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_test.go @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "strings" + "testing" +) + +// TestStreamManagedSSE_TextDeltas asserts only output_text.delta events are +// rendered, in order, with a trailing newline, and that lifecycle events are +// consumed silently. +func TestStreamManagedSSE_TextDeltas(t *testing.T) { + sse := strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created"}`, + "", + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":"Hello"}`, + "", + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":", world"}`, + "", + "event: response.completed", + `data: {"type":"response.completed"}`, + "", + }, "\n") + + var out strings.Builder + if err := streamManagedSSE(strings.NewReader(sse), &out); err != nil { + t.Fatalf("streamManagedSSE: %v", err) + } + got := out.String() + want := "Hello, world\n" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// TestStreamManagedSSE_NoText asserts that a stream with no text deltas +// produces no output (and notably no trailing newline). +func TestStreamManagedSSE_NoText(t *testing.T) { + sse := strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created"}`, + "", + "event: response.completed", + `data: {"type":"response.completed"}`, + "", + }, "\n") + + var out strings.Builder + if err := streamManagedSSE(strings.NewReader(sse), &out); err != nil { + t.Fatalf("streamManagedSSE: %v", err) + } + if out.String() != "" { + t.Errorf("expected empty output, got %q", out.String()) + } +} + +// TestStreamManagedSSE_IgnoresMalformedData asserts a malformed data line does +// not abort the stream or emit garbage. +func TestStreamManagedSSE_IgnoresMalformedData(t *testing.T) { + sse := strings.Join([]string{ + "event: response.output_text.delta", + `data: {not valid json`, + "", + "event: response.output_text.delta", + `data: {"delta":"ok"}`, + "", + }, "\n") + + var out strings.Builder + if err := streamManagedSSE(strings.NewReader(sse), &out); err != nil { + t.Fatalf("streamManagedSSE: %v", err) + } + if out.String() != "ok\n" { + t.Errorf("got %q, want %q", out.String(), "ok\n") + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/list.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/list.go new file mode 100644 index 00000000000..95168076690 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/list.go @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type listFlags struct { + output string + noPrompt bool +} + +// newListCommand creates `azd ai agent list`. It enumerates the prompt agents +// registered on the harness configured for the azure.ai.agent service in the +// current azd project (azure.yaml). +func newListCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &listFlags{} + extCtx = ensureExtensionContext(extCtx) + + cmd := &cobra.Command{ + Use: "list", + Short: "List prompt agents on the harness.", + Long: `List the prompt agents registered on the managed harness. + +The target harness is read from the azure.ai.agent service config in azure.yaml +(written by 'azd ai agent init'). This command targets prompt agents only.`, + Example: ` # List prompt agents on the configured harness + azd ai agent list + + # List as JSON + azd ai agent list --output json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + flags.output = extCtx.OutputFormat + flags.noPrompt = extCtx.NoPrompt + + ctx := azdext.WithAccessToken(cmd.Context()) + + action := &ListAction{flags: flags} + return action.Run(ctx) + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "table"}, + Default: "table", + }) + + return cmd +} + +// ListAction implements the prompt agent list command. +type ListAction struct { + flags *listFlags +} + +func (a *ListAction) Run(ctx context.Context) error { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + pctx, isPrompt, err := resolvePromptAgentService(ctx, azdClient, "", a.flags.noPrompt) + if err != nil { + return err + } + if !isPrompt { + return fmt.Errorf( + "the azure.ai.agent service is not a prompt agent; `azd ai agent list` targets prompt agents only", + ) + } + + client, err := pctx.newClient() + if err != nil { + return err + } + + list, err := client.ListAgents(ctx, nil, pctx.Settings.EffectiveAPIVersion()) + if err != nil { + return fmt.Errorf("failed to list prompt agents: %w", err) + } + + switch a.flags.output { + case "json": + data, jsonErr := json.MarshalIndent(list, "", " ") + if jsonErr != nil { + return fmt.Errorf("failed to marshal response: %w", jsonErr) + } + fmt.Println(string(data)) + default: + printPromptListTable(list, pctx.Settings) + } + return nil +} + +// printPromptListTable renders a concise table of prompt agents. +func printPromptListTable(list *agent_api.AgentList, settings *project.PromptAgentSettings) { + if list == nil || len(list.Data) == 0 { + fmt.Printf("No prompt agents found on %s.\n", settings.BaseURL) + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tVERSION\tSTATUS") + for _, agent := range list.Data { + latest := agent.Versions.Latest + version := latest.Version + if version == "" { + version = "-" + } + status := latest.Status + if status == "" { + status = "-" + } + fmt.Fprintf(w, "%s\t%s\t%s\n", agent.Name, version, status) + } + _ = w.Flush() +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 4be101a9a60..286f4f645f6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -61,8 +61,16 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args for _, svc := range args.Project.Services { switch svc.Host { case AiAgentHost: - if err := populateContainerSettings(ctx, azdClient, svc); err != nil { - return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) + // Prompt (kind=managed) agents have no container to provision + // settings for — the harness owns the runtime. But they DO carry a + // model deployment in their service config, so still run envUpdate + // (which translates `deployments` into AI_PROJECT_DEPLOYMENTS for + // Bicep). Only the container-settings step is hosted-specific. + _, isPrompt := promptSettingsFromService(svc) + if !isPrompt { + if err := populateContainerSettings(ctx, azdClient, svc); err != nil { + return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) + } } if err := envUpdate(ctx, azdClient, args.Project, svc); err != nil { return fmt.Errorf("failed to update environment for service %q: %w", svc.Name, err) @@ -85,6 +93,14 @@ func postprovisionHandler( } hasAgent = true + // Prompt (kind=managed) agents have no toolboxes to provision on a + // Foundry project — the harness owns those. Skip toolbox provisioning + // but still treat the project as having an agent (for the + // pending-provision signal clear below). + if _, isPrompt := promptSettingsFromService(svc); isPrompt { + continue + } + if err := provisionToolboxes(ctx, azdClient, svc); err != nil { return fmt.Errorf( "failed to provision toolboxes for service %q: %w", @@ -158,6 +174,12 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az continue } + // Prompt (kind=managed) agents have no container settings and no + // developer-RBAC pre-flight — the harness owns the runtime. + if _, isPrompt := promptSettingsFromService(svc); isPrompt { + continue + } + if err := populateContainerSettings(ctx, azdClient, svc); err != nil { return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) } @@ -335,6 +357,13 @@ func postdownHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azd continue } + // Prompt (kind=managed) agents are removed from the harness on down so + // `azd down` fully tears down the agent alongside the infrastructure. + // Best-effort: a harness failure is logged but does not block down. + if settings, isPrompt := promptSettingsFromService(svc); isPrompt { + deletePromptAgentOnDown(ctx, svc, settings) + } + if cleanupAgentSessionState(ctx, azdClient, envName, svc.Name) { fmt.Printf("Cleaned up saved session and conversation for agent %q\n", svc.Name) } @@ -343,6 +372,31 @@ func postdownHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azd return nil } +// deletePromptAgentOnDown best-effort deletes a prompt agent from the harness +// during `azd down`. Failures are logged, never returned — teardown of the +// project should not be blocked by a harness hiccup. +func deletePromptAgentOnDown( + ctx context.Context, + svc *azdext.ServiceConfig, + settings *project.PromptAgentSettings, +) { + settings.ApplyEnvOverrides() + if err := settings.Validate(); err != nil { + log.Printf("postdown: skipping harness delete for %q: %v", svc.Name, err) + return + } + client, err := project.NewPromptAgentClient(settings) + if err != nil { + log.Printf("postdown: failed to build harness client for %q: %v", svc.Name, err) + return + } + if _, err := client.DeleteAgent(ctx, svc.Name, settings.EffectiveAPIVersion(), true); err != nil { + log.Printf("postdown: failed to delete prompt agent %q from harness: %v", svc.Name, err) + return + } + fmt.Printf("Deleted prompt agent %q from the harness\n", svc.Name) +} + // cleanupAgentSessionState removes saved session and conversation IDs for a // single agent service. Returns true if cleanup succeeded, false otherwise. // Shared by postdownHandler and delete command. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go index 86104412b84..b7778a6acdc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go @@ -6,6 +6,7 @@ package cmd import ( "fmt" "net/url" + "os" "strings" "azureaiagent/internal/exterrors" @@ -38,6 +39,24 @@ var foundryHostSuffixes = []string{ // projectEndpointPathPrefix is the expected path prefix for Foundry project endpoints. const projectEndpointPathPrefix = "/api/projects/" +// FoundryEndpointOverrideEnvVar is the environment variable that, when set, +// causes the project-endpoint validator to skip the Foundry host suffix check +// and accept http:// (in addition to https://). It exists so developers can +// point the extension at a locally running Foundry backend (e.g. the vienna +// "managed-harness" service on http://localhost:5000) for end-to-end testing. +// +// IMPORTANT: This bypass is for development/testing only. Never document it +// in user-facing help; it is intentionally undocumented and may change or be +// removed at any time. +const FoundryEndpointOverrideEnvVar = "AZD_FOUNDRY_ENDPOINT_OVERRIDE" + +// foundryEndpointValidationBypassed reports whether the +// AZD_FOUNDRY_ENDPOINT_OVERRIDE environment variable is set to any non-empty +// value. When true, validateProjectEndpoint relaxes its scheme and host checks. +func foundryEndpointValidationBypassed() bool { + return strings.TrimSpace(os.Getenv(FoundryEndpointOverrideEnvVar)) != "" +} + // isFoundryHost reports whether the hostname ends with one of the recognized // Foundry host suffixes. func isFoundryHost(hostname string) bool { @@ -78,7 +97,12 @@ func validateProjectEndpoint(raw string) (normalized string, pathWarning bool, e ) } - if !strings.EqualFold(u.Scheme, "https") { + // When the override env var is set we accept http:// in addition to + // https:// so developers can target a locally running Foundry backend. + bypass := foundryEndpointValidationBypassed() + + if !strings.EqualFold(u.Scheme, "https") && + !(bypass && strings.EqualFold(u.Scheme, "http")) { return "", false, exterrors.Validation( exterrors.CodeInvalidParameter, "project endpoint must use https", @@ -87,7 +111,14 @@ func validateProjectEndpoint(raw string) (normalized string, pathWarning bool, e } host := u.Hostname() - if host == "" || !isFoundryHost(host) { + if host == "" { + return "", false, exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint host must not be empty", + "provide a URL with a hostname", + ) + } + if !bypass && !isFoundryHost(host) { return "", false, exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf( @@ -98,7 +129,7 @@ func validateProjectEndpoint(raw string) (normalized string, pathWarning bool, e ) } - if u.Port() != "" { + if !bypass && u.Port() != "" { return "", false, exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf("project endpoint host %q must not include a port", u.Host), @@ -106,13 +137,21 @@ func validateProjectEndpoint(raw string) (normalized string, pathWarning bool, e ) } - // Normalize: lowercase host, strip trailing slash. + // Normalize: lowercase host, strip trailing slash. Preserve the scheme as + // originally supplied so the override path can keep http:// for localhost. + scheme := strings.ToLower(u.Scheme) path := strings.TrimRight(u.EscapedPath(), "/") - normalized = fmt.Sprintf("https://%s%s", strings.ToLower(host), path) + hostPart := strings.ToLower(host) + if u.Port() != "" { + hostPart = fmt.Sprintf("%s:%s", hostPart, u.Port()) + } + normalized = fmt.Sprintf("%s://%s%s", scheme, hostPart, path) - // Warn when the path does not look like /api/projects/. - if !strings.HasPrefix(path, projectEndpointPathPrefix) || - strings.TrimPrefix(path, projectEndpointPathPrefix) == "" { + // Warn when the path does not look like /api/projects/. The override + // path skips this warning entirely — local backends often expose a simpler + // path layout. + if !bypass && (!strings.HasPrefix(path, projectEndpointPathPrefix) || + strings.TrimPrefix(path, projectEndpointPathPrefix) == "") { pathWarning = true } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint_test.go index b5f8a6de323..5b2ed87736c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint_test.go @@ -106,3 +106,61 @@ func TestNoProjectEndpointError(t *testing.T) { assert.Equal(t, exterrors.CodeMissingProjectEndpoint, localErr.Code) assert.Equal(t, azdext.LocalErrorCategoryDependency, localErr.Category) } + +// TestValidateProjectEndpoint_OverrideBypass verifies that when the +// AZD_FOUNDRY_ENDPOINT_OVERRIDE env var is set, the validator accepts +// http:// URLs targeting localhost (or any host) with an explicit port. This +// is the developer-only path used to point the extension at a locally +// running Foundry backend such as the vienna managed-harness service. +// +// The test cannot use t.Parallel() because t.Setenv mutates process-global +// state; the validator reads the env var on every call. +func TestValidateProjectEndpoint_OverrideBypass(t *testing.T) { + t.Setenv(FoundryEndpointOverrideEnvVar, "1") + + cases := []struct { + name string + input string + want string + }{ + { + name: "http localhost with port", + input: "http://localhost:5000", + want: "http://localhost:5000", + }, + { + name: "http loopback ipv4", + input: "http://127.0.0.1:5000", + want: "http://127.0.0.1:5000", + }, + { + name: "https arbitrary host with port", + input: "https://my-dev-box.internal:8443", + want: "https://my-dev-box.internal:8443", + }, + { + name: "trailing slash stripped", + input: "http://localhost:5000/", + want: "http://localhost:5000", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, _, err := validateProjectEndpoint(tc.input) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestValidateProjectEndpoint_OverrideOff verifies the bypass is opt-in: +// when the env var is empty the validator restores its strict checks. +func TestValidateProjectEndpoint_OverrideOff(t *testing.T) { + t.Setenv(FoundryEndpointOverrideEnvVar, "") + + _, _, err := validateProjectEndpoint("http://localhost:5000") + require.Error(t, err, "http://localhost should be rejected when override is off") + + _, _, err = validateProjectEndpoint("https://example.com/api/projects/p") + require.Error(t, err, "non-foundry host should be rejected when override is off") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go new file mode 100644 index 00000000000..2ae9bb235d0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "os" + "path/filepath" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/paths" + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "go.yaml.in/yaml/v3" +) + +// promptServiceContext carries everything the prompt-agent commands +// (show/invoke/list/delete) need to talk to the harness for a resolved +// azure.ai.agent service of kind=managed. +type promptServiceContext struct { + ServiceName string + ServiceDir string + Settings *project.PromptAgentSettings + Agent agent_yaml.ManagedAgent +} + +// promptSettingsFromService extracts the prompt-agent harness settings from a +// service config. The bool is false when the service is not a prompt agent +// (no promptAgent block), letting callers fall back to the hosted path. +func promptSettingsFromService(svc *azdext.ServiceConfig) (*project.PromptAgentSettings, bool) { + if svc == nil || svc.Config == nil { + return nil, false + } + var cfg project.ServiceTargetAgentConfig + if err := project.UnmarshalStruct(svc.Config, &cfg); err != nil { + return nil, false + } + if cfg.PromptAgent == nil { + return nil, false + } + return cfg.PromptAgent, true +} + +// resolvePromptAgentService resolves the named (or sole) azure.ai.agent service +// and, when it is a prompt (kind=managed) agent, returns its harness settings +// and parsed agent.yaml. The bool is false when the resolved service is NOT a +// prompt agent, so callers can fall back to the hosted code path. +func resolvePromptAgentService( + ctx context.Context, + azdClient *azdext.AzdClient, + name string, + noPrompt bool, +) (*promptServiceContext, bool, error) { + svc, proj, err := resolveAgentService(ctx, azdClient, name, noPrompt) + if err != nil { + return nil, false, err + } + + settings, ok := promptSettingsFromService(svc) + if !ok { + return nil, false, nil + } + settings.ApplyEnvOverrides() + if err := settings.Validate(); err != nil { + return nil, false, err + } + + // Apply the same azd environment-derived target resolution that deploy uses + // so lifecycle commands (show/invoke/list/delete) hit the identical managed + // workspace route (@@AML) the agent was created on. Without + // this, these commands resolve promptAgent.workspace from azure.yaml verbatim + // and query a non-existent workspace, yielding an HTML 404 the client cannot + // parse. + if envValues, envErr := promptEnvValues(ctx, azdClient); envErr == nil { + if _, mapErr := project.ResolvePromptTargetFromEnv(settings, envValues); mapErr != nil { + return nil, false, mapErr + } + } + + pctx := &promptServiceContext{ + ServiceName: svc.Name, + Settings: settings, + } + + if proj != nil { + if dir, joinErr := paths.JoinAllowRoot(proj.Path, svc.RelativePath); joinErr == nil { + pctx.ServiceDir = dir + } + } + + // Parse the agent.yaml that backs the service to recover the model and + // (default) agent name. Best-effort: the service Name is used as the agent + // identity when agent.yaml cannot be read. + pctx.Agent.Name = svc.Name + if pctx.ServiceDir != "" { + if data, readErr := os.ReadFile(filepath.Join(pctx.ServiceDir, "agent.yaml")); readErr == nil { + var managed agent_yaml.ManagedAgent + if yaml.Unmarshal(data, &managed) == nil && managed.Name != "" { + pctx.Agent = managed + } + } + } + + return pctx, true, nil +} + +// AgentName returns the harness agent identity for the resolved service. +func (p *promptServiceContext) AgentName() string { + if p.Agent.Name != "" { + return p.Agent.Name + } + return p.ServiceName +} + +// newClient builds a harness client for the resolved prompt service. +func (p *promptServiceContext) newClient() (*agent_api.ManagedAgentClient, error) { + return project.NewPromptAgentClient(p.Settings) +} + +// promptEnvValues returns the current azd environment as a key/value map. It is +// used to apply the same Foundry project -> managed workspace resolution that +// deploy performs, so lifecycle commands target the route the agent lives on. +func promptEnvValues(ctx context.Context, azdClient *azdext.AzdClient) (map[string]string, error) { + envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, err + } + values, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: envResp.Environment.Name, + }) + if err != nil { + return nil, err + } + out := make(map[string]string, len(values.KeyValues)) + for _, kv := range values.KeyValues { + out[kv.Key] = kv.Value + } + return out, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go index 48e2e6e352a..81b708d48b6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -57,6 +57,8 @@ func NewRootCommand() *cobra.Command { return rootCmd })) rootCmd.AddCommand(newShowCommand(extCtx)) + rootCmd.AddCommand(newDeployCommand(extCtx)) + rootCmd.AddCommand(newListCommand(extCtx)) rootCmd.AddCommand(newDeleteCommand(extCtx)) rootCmd.AddCommand(newEndpointCommand(extCtx)) rootCmd.AddCommand(newMonitorCommand(extCtx)) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go index 5cf21274b5a..3fd474a36a5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go @@ -46,8 +46,8 @@ func newShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { cmd := &cobra.Command{ Use: "show [name]", - Short: "Show the status of a hosted agent.", - Long: `Show the status of a hosted agent. + Short: "Show the status of an agent.", + Long: `Show the status of an agent. The agent name and version are resolved automatically from the azure.yaml service configuration and the current azd environment. Optionally specify the service name @@ -75,6 +75,18 @@ configuration and the current azd environment. Optionally specify the service na } defer azdClient.Close() + // Prompt (kind=managed) agents are azd services too, but they live + // on the harness rather than the Foundry service. Resolve the + // service and, when it is a prompt agent, query the harness for + // status instead of the Foundry agent endpoint. + if pctx, isPrompt, pErr := resolvePromptAgentService( + ctx, azdClient, flags.name, extCtx.NoPrompt, + ); pErr != nil { + return pErr + } else if isPrompt { + return runPromptShow(ctx, flags, pctx) + } + info, err := resolveAgentServiceFromProject(ctx, azdClient, flags.name, extCtx.NoPrompt) if err != nil { return err @@ -196,6 +208,54 @@ func (a *ShowAction) Run(ctx context.Context) error { return printShowResult(result, a.flags.output, suggestions) } +// runPromptShow handles `azd ai agent show` for a prompt (kind=managed) agent. +// It is dispatched from RunE when the resolved azure.ai.agent service carries a +// promptAgent config block. The status comes from the harness GetAgent API +// rather than the Foundry agent endpoint. +func runPromptShow(ctx context.Context, flags *showFlags, pctx *promptServiceContext) error { + agentName := pctx.AgentName() + client, err := pctx.newClient() + if err != nil { + return err + } + + agent, err := client.GetAgent(ctx, agentName, pctx.Settings.EffectiveAPIVersion()) + if err != nil { + return fmt.Errorf("failed to get prompt agent %q: %w", agentName, err) + } + + switch flags.output { + case "json": + data, jsonErr := json.MarshalIndent(agent, "", " ") + if jsonErr != nil { + return fmt.Errorf("failed to marshal response: %w", jsonErr) + } + fmt.Println(string(data)) + default: + printPromptShowTable(agent, pctx.Settings) + } + return nil +} + +// printPromptShowTable renders a concise status table for a prompt agent. +func printPromptShowTable(agent *agent_api.AgentObject, settings *projectpkg.PromptAgentSettings) { + latest := agent.Versions.Latest + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "Name:\t%s\n", agent.Name) + fmt.Fprintf(w, "Kind:\t%s\n", "prompt") + if latest.Version != "" { + fmt.Fprintf(w, "Version:\t%s\n", latest.Version) + } + if latest.Status != "" { + fmt.Fprintf(w, "Status:\t%s\n", latest.Status) + } + fmt.Fprintf(w, "Harness:\t%s\n", settings.BaseURL) + if latest.Error != nil && latest.Error.Message != "" { + fmt.Fprintf(w, "Error:\t%s (%s)\n", latest.Error.Message, latest.Error.Code) + } + _ = w.Flush() +} + func printShowResult(result *showResult, output string, suggestions []nextstep.Suggestion) error { switch output { case "", "table": diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go new file mode 100644 index 00000000000..99dba046a0f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go @@ -0,0 +1,661 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "azureaiagent/internal/version" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" +) + +// ManagedAgentClient talks to the Foundry "managed" agent surface (the +// PES-backed Brain+Hand orchestration). It differs from AgentClient in two +// ways: +// +// 1. URLs are ARM-shaped — every operation is rooted at a workspace resource +// (subscription / resourceGroup / workspace) rather than a Foundry project +// endpoint. +// 2. Responses go through the v2.0 controller, which dispatches managed +// agents to the V3 harness engine on the backend. +// +// The client is intentionally configured by a base URL plus a route prefix so +// callers can point it at either the production ARM control plane or a local +// development backend (e.g. the vienna "managed-harness" service running on +// http://localhost:5000) without leaking shape assumptions into this package. +type ManagedAgentClient struct { + // baseURL is the scheme+host+optional-port of the service. No trailing slash. + baseURL string + // routePrefix is the URL segment between baseURL and the per-operation + // suffix. It must NOT contain "/agents" — callers supply only the + // workspace-rooted portion (e.g. + // "/agents/v2.0/subscriptions/.../workspaces/"). No trailing slash. + routePrefix string + pipeline runtime.Pipeline + credential azcore.TokenCredential +} + +// ManagedAgentClientOptions are construction-time options for ManagedAgentClient. +type ManagedAgentClientOptions struct { + // BaseURL is the service origin (e.g. "https://management.azure.com" or + // "http://localhost:5000"). Required. + BaseURL string + // RoutePrefix is the ARM-style workspace prefix the backend expects between + // the origin and the per-operation suffix. Must start with "/" and must + // not end with "/". Example: + // + // /agents/v2.0/subscriptions//resourceGroups//providers/Microsoft.MachineLearningServices/workspaces/ + // + // Required. + RoutePrefix string + // Credential is the token credential used to acquire bearer tokens. May be + // nil when targeting an unauthenticated local backend; in that case no + // authorization policy is attached to the pipeline. + Credential azcore.TokenCredential + // Scopes are the OAuth scopes requested when Credential is non-nil. + // Defaults to {"https://ai.azure.com/.default"}. + Scopes []string +} + +// NewManagedAgentClient builds a ManagedAgentClient from the given options. +// Returns an error when BaseURL or RoutePrefix is malformed. +func NewManagedAgentClient(opts ManagedAgentClientOptions) (*ManagedAgentClient, error) { + base := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/") + if base == "" { + return nil, fmt.Errorf("ManagedAgentClient: BaseURL is required") + } + parsed, err := url.Parse(base) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("ManagedAgentClient: BaseURL %q is not a valid absolute URL", opts.BaseURL) + } + + prefix := strings.TrimRight(strings.TrimSpace(opts.RoutePrefix), "/") + if prefix == "" { + return nil, fmt.Errorf("ManagedAgentClient: RoutePrefix is required") + } + if !strings.HasPrefix(prefix, "/") { + return nil, fmt.Errorf("ManagedAgentClient: RoutePrefix %q must start with '/'", opts.RoutePrefix) + } + + userAgent := fmt.Sprintf("azd-ext-azure-ai-agents/%s", version.Version) + + perCall := []policy.Policy{ + azsdk.NewMsCorrelationPolicy(), + azsdk.NewUserAgentPolicy(userAgent), + } + if opts.Credential != nil { + scopes := opts.Scopes + if len(scopes) == 0 { + scopes = []string{"https://ai.azure.com/.default"} + } + // The local managed-harness is served over plain HTTP + // (http://localhost:5000) but still validates a bearer token. azcore + // refuses to attach credentials to non-TLS endpoints unless this is + // explicitly opted into, so allow it when (and only when) the base URL + // is http — production https endpoints keep the default protection. + var bearerOpts *policy.BearerTokenOptions + if parsed.Scheme == "http" { + bearerOpts = &policy.BearerTokenOptions{ + InsecureAllowCredentialWithHTTP: true, + } + } + perCall = append([]policy.Policy{ + runtime.NewBearerTokenPolicy(opts.Credential, scopes, bearerOpts), + }, perCall...) + } + + clientOptions := &policy.ClientOptions{ + Logging: policy.LogOptions{ + AllowedHeaders: []string{"X-Ms-Correlation-Request-Id", "X-Request-Id"}, + IncludeBody: true, + }, + PerCallPolicies: perCall, + } + + pipeline := runtime.NewPipeline( + "azure-ai-agents-managed", + "v1.0.0", + runtime.PipelineOptions{}, + clientOptions, + ) + + return &ManagedAgentClient{ + baseURL: base, + routePrefix: prefix, + pipeline: pipeline, + credential: opts.Credential, + }, nil +} + +// agentsURL builds the URL for a managed-agents lifecycle operation. The +// optional pathSuffix is appended after "/agents" (it must start with "/" or +// be empty). Query parameters from extraQuery (which may include +// "api-version") are added to the final URL. +func (c *ManagedAgentClient) agentsURL(pathSuffix string, extraQuery url.Values) string { + u := c.baseURL + c.routePrefix + "/agents" + pathSuffix + if len(extraQuery) > 0 { + u = u + "?" + extraQuery.Encode() + } + return u +} + +// responsesURL builds the URL for an OpenAI-shape Responses operation. The +// Foundry project data-plane exposes a path-versioned OpenAI surface +// ("/openai/v1/responses") and rejects an api-version query parameter. The +// target agent travels in the request body as +// `agent_reference: { type: "agent_reference", name }`. pathSuffix is appended +// after "/openai/v1/responses" (must start with "/" or be empty). +func (c *ManagedAgentClient) responsesURL(pathSuffix string) string { + return c.baseURL + c.routePrefix + "/openai/v1/responses" + pathSuffix +} + +// CreateAgent creates a managed agent. +// +// POST {baseURL}{routePrefix}/agents?api-version= +func (c *ManagedAgentClient) CreateAgent( + ctx context.Context, + request *CreateAgentRequest, + apiVersion string, +) (*AgentObject, error) { + return c.CreateAgentWithHeaders(ctx, request, apiVersion, nil) +} + +// CreateAgentWithHeaders creates a managed agent and forwards any additional +// headers to the request. This is used by prompt-agent flows that need to +// pass backend routing hints such as x-model-endpoint. +func (c *ManagedAgentClient) CreateAgentWithHeaders( + ctx context.Context, + request *CreateAgentRequest, + apiVersion string, + headers map[string]string, +) (*AgentObject, error) { + q := url.Values{} + if apiVersion != "" { + q.Set("api-version", apiVersion) + } + + payload, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPost, c.agentsURL("", q)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + for k, v := range headers { + req.Raw().Header.Set(k, v) + } + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil { + return nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var agent AgentObject + if err := json.Unmarshal(body, &agent); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &agent, nil +} + +// GetAgent retrieves a managed agent by name. +// +// GET {baseURL}{routePrefix}/agents/{name}?api-version= +func (c *ManagedAgentClient) GetAgent( + ctx context.Context, + agentName, apiVersion string, +) (*AgentObject, error) { + if strings.TrimSpace(agentName) == "" { + return nil, fmt.Errorf("agentName is required") + } + q := url.Values{} + if apiVersion != "" { + q.Set("api-version", apiVersion) + } + + req, err := runtime.NewRequest(ctx, http.MethodGet, c.agentsURL("/"+url.PathEscape(agentName), q)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var agent AgentObject + if err := json.Unmarshal(body, &agent); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &agent, nil +} + +// UpdateAgent replaces an existing managed agent's definition. +// +// POST {baseURL}{routePrefix}/agents/{name}?api-version= +func (c *ManagedAgentClient) UpdateAgent( + ctx context.Context, + agentName string, + request *UpdateAgentRequest, + apiVersion string, +) (*AgentObject, error) { + if strings.TrimSpace(agentName) == "" { + return nil, fmt.Errorf("agentName is required") + } + q := url.Values{} + if apiVersion != "" { + q.Set("api-version", apiVersion) + } + + payload, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPost, c.agentsURL("/"+url.PathEscape(agentName), q)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil { + return nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var agent AgentObject + if err := json.Unmarshal(body, &agent); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &agent, nil +} + +// DeleteAgent removes a managed agent. When force is true, the agent is +// deleted even if it has active sessions; when false the force query param +// is omitted entirely (matches the vienna harness default). +// +// DELETE {baseURL}{routePrefix}/agents/{name}?api-version=[&force=true] +func (c *ManagedAgentClient) DeleteAgent( + ctx context.Context, + agentName, apiVersion string, + force bool, +) (*DeleteAgentResponse, error) { + if strings.TrimSpace(agentName) == "" { + return nil, fmt.Errorf("agentName is required") + } + q := url.Values{} + if apiVersion != "" { + q.Set("api-version", apiVersion) + } + if force { + q.Set("force", strconv.FormatBool(force)) + } + + req, err := runtime.NewRequest(ctx, http.MethodDelete, c.agentsURL("/"+url.PathEscape(agentName), q)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + // Accept both 200 (body) and 204 (no body) — vienna returns 204 in some configs. + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusNoContent) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var deleteResponse DeleteAgentResponse + if len(body) > 0 { + if err := json.Unmarshal(body, &deleteResponse); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + } else { + deleteResponse = DeleteAgentResponse{Deleted: true, Name: agentName} + } + return &deleteResponse, nil +} + +// ListAgents returns the managed agents in the workspace. +// +// GET {baseURL}{routePrefix}/agents?api-version=[&kind=...&limit=...&after=...&before=...&order=...] +func (c *ManagedAgentClient) ListAgents( + ctx context.Context, + params *ListAgentQueryParameters, + apiVersion string, +) (*AgentList, error) { + q := url.Values{} + if apiVersion != "" { + q.Set("api-version", apiVersion) + } + if params != nil { + if params.Kind != nil { + q.Set("kind", string(*params.Kind)) + } + if params.Limit != nil { + q.Set("limit", strconv.Itoa(int(*params.Limit))) + } + if params.After != nil { + q.Set("after", *params.After) + } + if params.Before != nil { + q.Set("before", *params.Before) + } + if params.Order != nil { + q.Set("order", *params.Order) + } + } + + req, err := runtime.NewRequest(ctx, http.MethodGet, c.agentsURL("", q)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var list AgentList + if err := json.Unmarshal(body, &list); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &list, nil +} + +// CreateResponse invokes a managed agent via the OpenAI-shape Responses API. +// +// POST {baseURL}{routePrefix}/openai/v1/responses +// +// The Responses surface is path-versioned (no api-version query). The target +// agent travels in the request body as +// `agent_reference: { type: "agent_reference", name: "" }`. The body +// is forwarded verbatim so callers can shape it as needed (input, model, +// tools, stream, etc.); the raw response body is returned so streaming +// (SSE) callers can scan it as it arrives. +func (c *ManagedAgentClient) CreateResponse( + ctx context.Context, + requestBody []byte, + headers map[string]string, +) ([]byte, http.Header, error) { + req, err := runtime.NewRequest(ctx, http.MethodPost, c.responsesURL("")) + if err != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", err) + } + for k, v := range headers { + req.Raw().Header.Set(k, v) + } + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(requestBody)), "application/json"); err != nil { + return nil, nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated, http.StatusAccepted) { + return nil, nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return body, resp.Header.Clone(), nil +} + +// CreateResponseStream is the streaming counterpart of CreateResponse. The +// raw HTTP response body is returned without being read so the caller can +// process the Server-Sent Events line-by-line as the harness emits them. +// The caller MUST close the returned body. +func (c *ManagedAgentClient) CreateResponseStream( + ctx context.Context, + requestBody []byte, + headers map[string]string, +) (io.ReadCloser, http.Header, error) { + req, err := runtime.NewRequest(ctx, http.MethodPost, c.responsesURL("")) + if err != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", err) + } + for k, v := range headers { + req.Raw().Header.Set(k, v) + } + // SSE responses are not buffered through azcore's body decoder — set + // Accept so the server picks the streaming representation when given a + // choice. + if req.Raw().Header.Get("Accept") == "" { + req.Raw().Header.Set("Accept", "text/event-stream") + } + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(requestBody)), "application/json"); err != nil { + return nil, nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("HTTP request failed: %w", err) + } + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated, http.StatusAccepted) { + // On non-success the body usually has a JSON error payload; let + // azcore parse it and close the body for us. + return nil, nil, runtime.NewResponseError(resp) + } + return resp.Body, resp.Header.Clone(), nil +} + +// GetResponse retrieves a previously created response by id. +// +// GET {baseURL}{routePrefix}/openai/v1/responses/{responseId} +func (c *ManagedAgentClient) GetResponse( + ctx context.Context, + responseID string, +) ([]byte, http.Header, error) { + if strings.TrimSpace(responseID) == "" { + return nil, nil, fmt.Errorf("responseID is required") + } + + req, err := runtime.NewRequest(ctx, http.MethodGet, c.responsesURL("/"+url.PathEscape(responseID))) + if err != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return body, resp.Header.Clone(), nil +} + +// CancelResponse cancels an in-flight response. +// +// POST {baseURL}{routePrefix}/openai/v1/responses/{responseId}/cancel +func (c *ManagedAgentClient) CancelResponse( + ctx context.Context, + responseID string, +) ([]byte, http.Header, error) { + if strings.TrimSpace(responseID) == "" { + return nil, nil, fmt.Errorf("responseID is required") + } + + req, err := runtime.NewRequest( + ctx, http.MethodPost, c.responsesURL("/"+url.PathEscape(responseID)+"/cancel"), + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusAccepted) { + return nil, nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return body, resp.Header.Clone(), nil +} + +// DeleteResponse deletes a stored response. +// +// DELETE {baseURL}{routePrefix}/openai/v1/responses/{responseId} +func (c *ManagedAgentClient) DeleteResponse( + ctx context.Context, + responseID string, +) error { + if strings.TrimSpace(responseID) == "" { + return fmt.Errorf("responseID is required") + } + + req, err := runtime.NewRequest(ctx, http.MethodDelete, c.responsesURL("/"+url.PathEscape(responseID))) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusNoContent) { + return runtime.NewResponseError(resp) + } + return nil +} + +// BuildWorkspaceRoutePrefix is a convenience builder for the ARM-shaped route +// prefix expected by managed agent operations. Use it when constructing a +// ManagedAgentClient against a workspace identified by its +// subscription/resource-group/workspace tuple: +// +// /agents/v2.0/subscriptions//resourceGroups//providers/Microsoft.MachineLearningServices/workspaces/ +// +// All three arguments are required and must be non-empty. +func BuildWorkspaceRoutePrefix(subscriptionID, resourceGroup, workspace string) (string, error) { + if strings.TrimSpace(subscriptionID) == "" { + return "", fmt.Errorf("subscriptionID is required") + } + if strings.TrimSpace(resourceGroup) == "" { + return "", fmt.Errorf("resourceGroup is required") + } + if strings.TrimSpace(workspace) == "" { + return "", fmt.Errorf("workspace is required") + } + return fmt.Sprintf( + "/agents/v2.0/subscriptions/%s/resourceGroups/%s/providers/Microsoft.MachineLearningServices/workspaces/%s", + url.PathEscape(subscriptionID), + url.PathEscape(resourceGroup), + url.PathEscape(workspace), + ), nil +} + +// SplitProjectEndpoint splits a Foundry project data-plane endpoint into the +// pieces a ManagedAgentClient needs. Given: +// +// https://.services.ai.azure.com/api/projects/ +// +// it returns BaseURL ("https://.services.ai.azure.com") and +// RoutePrefix ("/api/projects/"). The client then assembles the +// canonical managed agent routes off that prefix, e.g.: +// +// {baseURL}{routePrefix}/agents +// {baseURL}{routePrefix}/openai/responses +func SplitProjectEndpoint(projectEndpoint string) (baseURL, routePrefix string, err error) { + pe := strings.TrimRight(strings.TrimSpace(projectEndpoint), "/") + if pe == "" { + return "", "", fmt.Errorf("projectEndpoint is required") + } + u, err := url.Parse(pe) + if err != nil || u.Scheme == "" || u.Host == "" { + return "", "", fmt.Errorf("projectEndpoint %q is not a valid absolute URL", projectEndpoint) + } + routePrefix = strings.TrimRight(u.Path, "/") + if routePrefix == "" { + return "", "", fmt.Errorf("projectEndpoint %q is missing the /api/projects/ path", projectEndpoint) + } + return u.Scheme + "://" + u.Host, routePrefix, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations_test.go new file mode 100644 index 00000000000..635e4a6f305 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations_test.go @@ -0,0 +1,357 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_api + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestBuildWorkspaceRoutePrefix_HappyPath verifies the helper produces the +// ARM-shaped path the vienna backend expects. +func TestBuildWorkspaceRoutePrefix_HappyPath(t *testing.T) { + prefix, err := BuildWorkspaceRoutePrefix("sub-1", "rg-x", "ws-y") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "/agents/v2.0/subscriptions/sub-1/resourceGroups/rg-x/" + + "providers/Microsoft.MachineLearningServices/workspaces/ws-y" + if prefix != want { + t.Errorf("prefix: got %q, want %q", prefix, want) + } +} + +// TestBuildWorkspaceRoutePrefix_RejectsMissingInputs covers each required arg. +func TestBuildWorkspaceRoutePrefix_RejectsMissingInputs(t *testing.T) { + cases := map[string]struct{ sub, rg, ws string }{ + "missing sub": {"", "rg", "ws"}, + "missing rg": {"sub", "", "ws"}, + "missing ws": {"sub", "rg", ""}, + "all blank": {" ", "\t", ""}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + _, err := BuildWorkspaceRoutePrefix(tc.sub, tc.rg, tc.ws) + if err == nil { + t.Fatalf("expected error for %s", name) + } + }) + } +} + +// TestSplitProjectEndpoint covers splitting a Foundry project data-plane +// endpoint into the client BaseURL and RoutePrefix, plus rejection of malformed +// inputs. +func TestSplitProjectEndpoint(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + base, prefix, err := SplitProjectEndpoint( + "https://acct.services.ai.azure.com/api/projects/proj", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if base != "https://acct.services.ai.azure.com" { + t.Errorf("base: got %q", base) + } + if prefix != "/api/projects/proj" { + t.Errorf("prefix: got %q", prefix) + } + // The assembled agents route must match the documented contract. + if got := base + prefix + "/agents"; got != + "https://acct.services.ai.azure.com/api/projects/proj/agents" { + t.Errorf("agents URL: got %q", got) + } + }) + + t.Run("trailing slash is tolerated", func(t *testing.T) { + base, prefix, err := SplitProjectEndpoint( + "https://acct.services.ai.azure.com/api/projects/proj/", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if base != "https://acct.services.ai.azure.com" || prefix != "/api/projects/proj" { + t.Errorf("got base=%q prefix=%q", base, prefix) + } + }) + + t.Run("rejects malformed inputs", func(t *testing.T) { + for _, in := range []string{"", " ", "not-a-url", "https://acct.services.ai.azure.com"} { + if _, _, err := SplitProjectEndpoint(in); err == nil { + t.Errorf("expected error for %q", in) + } + } + }) +} + +// TestNewManagedAgentClient_RejectsBadOptions covers the construction-time +// validation surface so callers get actionable failures rather than nil +// dereferences inside operation calls. +func TestNewManagedAgentClient_RejectsBadOptions(t *testing.T) { + cases := []struct { + name string + opts ManagedAgentClientOptions + wantSubstr string + expectError bool + }{ + { + name: "missing base URL", + opts: ManagedAgentClientOptions{RoutePrefix: "/agents/v2.0/x"}, + wantSubstr: "BaseURL", + expectError: true, + }, + { + name: "missing route prefix", + opts: ManagedAgentClientOptions{BaseURL: "https://example.com"}, + wantSubstr: "RoutePrefix", + expectError: true, + }, + { + name: "route prefix missing leading slash", + opts: ManagedAgentClientOptions{ + BaseURL: "https://example.com", + RoutePrefix: "agents/v2.0/x", + }, + wantSubstr: "/", + expectError: true, + }, + { + name: "valid", + opts: ManagedAgentClientOptions{ + BaseURL: "http://localhost:5000", + RoutePrefix: "/agents/v2.0/subscriptions/sub/resourceGroups/rg/providers/Microsoft.MachineLearningServices/workspaces/ws", + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client, err := NewManagedAgentClient(tc.opts) + if tc.expectError { + if err == nil { + t.Fatalf("expected error containing %q", tc.wantSubstr) + } + if !strings.Contains(err.Error(), tc.wantSubstr) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantSubstr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if client == nil { + t.Fatal("expected non-nil client") + } + }) + } +} + +// TestManagedAgentClient_CreateAgent_URLAndBody verifies that CreateAgent +// targets the expected ARM-rooted path, sets api-version, and forwards the +// JSON request body verbatim. Uses an httptest server in place of the real +// backend to keep the test hermetic. +func TestManagedAgentClient_CreateAgent_URLAndBody(t *testing.T) { + var ( + gotPath string + gotMethod string + gotQuery string + gotCT string + gotBody []byte + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + gotQuery = r.URL.RawQuery + gotCT = r.Header.Get("Content-Type") + gotBody, _ = io.ReadAll(r.Body) + _, _ = w.Write([]byte(`{"object":"agent","id":"agt_1","name":"my-managed","versions":{"latest":{"object":"agent_version","id":"v1","name":"my-managed","version":"1"}}}`)) + })) + defer srv.Close() + + prefix, err := BuildWorkspaceRoutePrefix("sub-1", "rg-x", "ws-y") + if err != nil { + t.Fatalf("prefix: %v", err) + } + client, err := NewManagedAgentClient(ManagedAgentClientOptions{ + BaseURL: srv.URL, + RoutePrefix: prefix, + }) + if err != nil { + t.Fatalf("NewManagedAgentClient: %v", err) + } + + req := &CreateAgentRequest{ + Name: "my-managed", + CreateAgentVersionRequest: CreateAgentVersionRequest{ + Definition: ManagedAgentDefinition{ + AgentDefinition: AgentDefinition{Kind: AgentKindManaged}, + Model: "gpt-4.1-mini", + Instructions: "Be helpful.", + }, + }, + } + agent, err := client.CreateAgent(context.Background(), req, "2025-08-01-preview") + if err != nil { + t.Fatalf("CreateAgent: %v", err) + } + if agent.Name != "my-managed" { + t.Errorf("agent.Name: got %q, want %q", agent.Name, "my-managed") + } + + if gotMethod != http.MethodPost { + t.Errorf("method: got %s, want POST", gotMethod) + } + wantPath := prefix + "/agents" + if gotPath != wantPath { + t.Errorf("path: got %q, want %q", gotPath, wantPath) + } + if gotQuery != "api-version=2025-08-01-preview" { + t.Errorf("query: got %q, want %q", gotQuery, "api-version=2025-08-01-preview") + } + if gotCT != "application/json" { + t.Errorf("content-type: got %q, want application/json", gotCT) + } + if !strings.Contains(string(gotBody), `"model":"gpt-4.1-mini"`) { + t.Errorf("body should contain model field, got: %s", string(gotBody)) + } + if !strings.Contains(string(gotBody), `"kind":"prompt"`) { + t.Errorf("body should contain kind discriminator, got: %s", string(gotBody)) + } +} + +// TestManagedAgentClient_DeleteAgent_URL verifies DELETE targets the expected +// path and threads the force flag into the query string. +func TestManagedAgentClient_DeleteAgent_URL(t *testing.T) { + var ( + gotPath string + gotQuery string + gotMethod string + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotMethod = r.Method + _, _ = w.Write([]byte(`{"object":"agent.deleted","id":"agt_1","name":"my-managed","deleted":true}`)) + })) + defer srv.Close() + + prefix, _ := BuildWorkspaceRoutePrefix("sub-1", "rg-x", "ws-y") + client, err := NewManagedAgentClient(ManagedAgentClientOptions{ + BaseURL: srv.URL, + RoutePrefix: prefix, + }) + if err != nil { + t.Fatalf("NewManagedAgentClient: %v", err) + } + + resp, err := client.DeleteAgent(context.Background(), "my-managed", "v1", true) + if err != nil { + t.Fatalf("DeleteAgent: %v", err) + } + if !resp.Deleted { + t.Errorf("expected Deleted=true, got %+v", resp) + } + + if gotMethod != http.MethodDelete { + t.Errorf("method: got %s, want DELETE", gotMethod) + } + wantPath := prefix + "/agents/my-managed" + if gotPath != wantPath { + t.Errorf("path: got %q, want %q", gotPath, wantPath) + } + if !strings.Contains(gotQuery, "api-version=v1") { + t.Errorf("query should contain api-version, got %q", gotQuery) + } + if !strings.Contains(gotQuery, "force=true") { + t.Errorf("query should contain force=true, got %q", gotQuery) + } +} + +// TestManagedAgentClient_CreateResponse_URL verifies that response creation +// targets the path-versioned /openai/v1/responses surface (no api-version +// query) and forwards the supplied JSON body and headers verbatim. The target +// agent travels in the body as `agent_reference`, not in the URL. +func TestManagedAgentClient_CreateResponse_URL(t *testing.T) { + var ( + gotPath string + gotRawQuery string + gotMethod string + gotModelEndpoint string + gotBody []byte + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotRawQuery = r.URL.RawQuery + gotMethod = r.Method + gotModelEndpoint = r.Header.Get("x-model-endpoint") + gotBody, _ = io.ReadAll(r.Body) + _, _ = w.Write([]byte(`{"id":"resp_1"}`)) + })) + defer srv.Close() + + base, prefix, _ := SplitProjectEndpoint(srv.URL + "/api/projects/proj") + client, _ := NewManagedAgentClient(ManagedAgentClientOptions{ + BaseURL: base, + RoutePrefix: prefix, + }) + + body, _, err := client.CreateResponse( + context.Background(), + []byte(`{"input":"hello","agent_reference":{"type":"agent_reference","name":"my-managed"}}`), + map[string]string{"x-model-endpoint": "https://aoai.example.com"}, + ) + if err != nil { + t.Fatalf("CreateResponse: %v", err) + } + if !strings.Contains(string(body), "resp_1") { + t.Errorf("body: %s", string(body)) + } + if gotMethod != http.MethodPost { + t.Errorf("method: got %s, want POST", gotMethod) + } + wantPath := "/api/projects/proj/openai/v1/responses" + if gotPath != wantPath { + t.Errorf("path: got %q, want %q", gotPath, wantPath) + } + if gotRawQuery != "" { + t.Errorf("query should be empty (path-versioned), got %q", gotRawQuery) + } + if gotModelEndpoint != "https://aoai.example.com" { + t.Errorf("x-model-endpoint header: got %q", gotModelEndpoint) + } + if !strings.Contains(string(gotBody), `"agent_reference"`) { + t.Errorf("body should forward agent_reference: %s", string(gotBody)) + } +} + +// TestManagedAgentClient_DeleteAgent_NoForce verifies that when force=false +// is passed the `force` query parameter is omitted entirely (matches the +// vienna e2e contract — only api-version is sent). +func TestManagedAgentClient_DeleteAgent_NoForce(t *testing.T) { + var gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + prefix, _ := BuildWorkspaceRoutePrefix("sub-1", "rg-x", "ws-y") + client, _ := NewManagedAgentClient(ManagedAgentClientOptions{ + BaseURL: srv.URL, + RoutePrefix: prefix, + }) + + if _, err := client.DeleteAgent(context.Background(), "my-managed", "v1", false); err != nil { + t.Fatalf("DeleteAgent: %v", err) + } + if strings.Contains(gotQuery, "force") { + t.Errorf("force should be omitted when false, got query %q", gotQuery) + } + if !strings.Contains(gotQuery, "api-version=v1") { + t.Errorf("query should retain api-version, got %q", gotQuery) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index a7d868b1816..0adb09bbc5c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -47,6 +47,10 @@ type AgentKind string const ( AgentKindHosted AgentKind = "hosted" AgentKindWorkflow AgentKind = "workflow" + // AgentKindManaged is the Foundry "managed" / "prompt" agent kind backed + // by the Prompt Execution Service (PES). The API control plane accepts the + // value "prompt" as the wire discriminator for this kind. + AgentKindManaged AgentKind = "prompt" ) // AgentEventType represents the types of events that can be handled @@ -241,6 +245,48 @@ func (d *HostedAgentDefinition) UnmarshalJSON(data []byte) error { return nil } +// ManagedPackages describes packages to install in the managed agent sandbox. +type ManagedPackages struct { + Pip []string `json:"pip,omitempty"` + Apt []string `json:"apt,omitempty"` +} + +// ManagedEnvironment describes the runtime environment for a managed agent's Hand sandbox. +// All fields are optional; the platform applies sensible defaults when unset. +type ManagedEnvironment struct { + BaseImage *string `json:"base_image,omitempty"` + Image *string `json:"image,omitempty"` + Packages *ManagedPackages `json:"packages,omitempty"` + CPU *string `json:"cpu,omitempty"` + Memory *string `json:"memory,omitempty"` + EgressPolicy *string `json:"egress_policy,omitempty"` + EnvironmentVariables map[string]string `json:"environment_variables,omitempty"` +} + +// ManagedAgentHarnessGitHubCopilot is the execution harness identifier sent in +// the managed agent definition's `harness` field to run the agent on the +// GitHub Copilot harness. +const ManagedAgentHarnessGitHubCopilot = "ghcp" + +// ManagedAgentDefinition represents a Foundry "managed" agent backed by the +// Prompt Execution Service (PES). Managed agents declare a model + instructions +// and optionally tools, skills, and environment overrides. The platform +// provisions Brain+Hand sandboxes on demand to execute the agent. +type ManagedAgentDefinition struct { + AgentDefinition + Model string `json:"model"` + // Harness identifies the execution harness the platform should use to run + // the managed agent (e.g. "ghcp" for the GitHub Copilot harness). + Harness string `json:"harness,omitempty"` + Instructions string `json:"instructions,omitempty"` + Tools []any `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Skills []string `json:"skills,omitempty"` + StructuredInputs map[string]any `json:"structured_inputs,omitempty"` + Environment *ManagedEnvironment `json:"environment,omitempty"` + Files map[string]string `json:"files,omitempty"` +} + // CreateAgentVersionRequest represents a request to create an agent version type CreateAgentVersionRequest struct { Description *string `json:"description,omitempty"` diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go new file mode 100644 index 00000000000..564b81cf4e0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "encoding/json" + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + + "go.yaml.in/yaml/v3" +) + +// TestExtractAgentDefinition_Managed_TemplateWrapper verifies the manifest +// parser routes a "managed" kind to a ManagedAgent value with all declared +// fields preserved. +func TestExtractAgentDefinition_Managed_TemplateWrapper(t *testing.T) { + yamlContent := []byte(` +name: my-managed-manifest +template: + kind: managed + name: my-managed + model: gpt-4.1-mini + instructions: You are a careful assistant. + skills: + - websearch + - code_interpreter +`) + agent, err := ExtractAgentDefinition(yamlContent) + if err != nil { + t.Fatalf("ExtractAgentDefinition failed: %v", err) + } + managed, ok := agent.(ManagedAgent) + if !ok { + t.Fatalf("expected ManagedAgent from template wrapper, got %T", agent) + } + if managed.Name != "my-managed" { + t.Errorf("name: got %q, want %q", managed.Name, "my-managed") + } + if managed.Kind != AgentKindManaged { + t.Errorf("kind: got %q, want %q", managed.Kind, AgentKindManaged) + } + if managed.Model != "gpt-4.1-mini" { + t.Errorf("model: got %q, want %q", managed.Model, "gpt-4.1-mini") + } + if managed.Instructions != "You are a careful assistant." { + t.Errorf("instructions: got %q", managed.Instructions) + } + if len(managed.Skills) != 2 { + t.Fatalf("skills: got %d entries, want 2", len(managed.Skills)) + } +} + +// TestManagedAgent_YAMLRoundTrip verifies a ManagedAgent value round-trips +// through yaml.Marshal / yaml.Unmarshal cleanly. This is the path used when +// writing agent.yaml from the init scaffolding and later reading it from disk +// as a bare AgentDefinition (without the manifest `template:` wrapper). +func TestManagedAgent_YAMLRoundTrip(t *testing.T) { + original := ManagedAgent{ + AgentDefinition: AgentDefinition{ + Name: "my-managed", + Kind: AgentKindManaged, + }, + Model: "gpt-4.1-mini", + Instructions: "Be helpful.", + } + data, err := yaml.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(data), "kind: managed") { + t.Fatalf("marshaled YAML missing kind discriminator:\n%s", data) + } + + var roundTripped ManagedAgent + if err := yaml.Unmarshal(data, &roundTripped); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if roundTripped.Model != original.Model { + t.Errorf("model: got %q, want %q", roundTripped.Model, original.Model) + } + if roundTripped.Instructions != original.Instructions { + t.Errorf("instructions: got %q, want %q", roundTripped.Instructions, original.Instructions) + } + if roundTripped.Kind != original.Kind { + t.Errorf("kind: got %q, want %q", roundTripped.Kind, original.Kind) + } +} + +// TestValidateAgentDefinition_Managed_RequiresModelAndInstructions ensures the +// validator surfaces actionable errors when required managed-agent fields are +// missing. +func TestValidateAgentDefinition_Managed_RequiresModelAndInstructions(t *testing.T) { + cases := []struct { + name string + yamlContent string + wantSubstr string + shouldError bool + }{ + { + name: "missing model", + yamlContent: ` +name: n +kind: managed +instructions: ok +`, + wantSubstr: "model", + shouldError: true, + }, + { + name: "missing instructions", + yamlContent: ` +name: n +kind: managed +model: gpt-4.1-mini +`, + wantSubstr: "instructions", + shouldError: true, + }, + { + name: "valid", + yamlContent: ` +name: n +kind: managed +model: gpt-4.1-mini +instructions: Be helpful. +`, + shouldError: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateAgentDefinition([]byte(tc.yamlContent)) + if tc.shouldError { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantSubstr) + } + if !strings.Contains(strings.ToLower(err.Error()), tc.wantSubstr) { + t.Errorf("error message %q does not contain %q", err.Error(), tc.wantSubstr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +// TestCreateManagedAgentAPIRequest_SetsHarness verifies the managed create +// request carries the GitHub Copilot harness identifier in the definition. +func TestCreateManagedAgentAPIRequest_SetsHarness(t *testing.T) { + managed := ManagedAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindManaged, + Name: "my-agent", + }, + Model: "gpt-4.1-mini", + Instructions: "Be helpful.", + } + + req, err := CreateManagedAgentAPIRequest(managed, nil) + if err != nil { + t.Fatalf("CreateManagedAgentAPIRequest: %v", err) + } + + def, ok := req.Definition.(agent_api.ManagedAgentDefinition) + if !ok { + t.Fatalf("definition: got %T, want agent_api.ManagedAgentDefinition", req.Definition) + } + if def.Harness != agent_api.ManagedAgentHarnessGitHubCopilot { + t.Errorf("harness: got %q, want %q", def.Harness, agent_api.ManagedAgentHarnessGitHubCopilot) + } + + // The serialized body must include "harness":"ghcp". + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + if !strings.Contains(string(data), `"harness":"ghcp"`) { + t.Errorf("serialized request missing harness field:\n%s", data) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index e9720dabcb5..91fd39f6748 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -133,8 +133,11 @@ func CreateAgentAPIRequestFromDefinition(agentTemplate any, options ...AgentBuil case AgentKindHosted: hostedDef := agentTemplate.(ContainerAgent) return CreateHostedAgentAPIRequest(hostedDef, buildConfig) + case AgentKindManaged: + managedDef := agentTemplate.(ManagedAgent) + return CreateManagedAgentAPIRequest(managedDef, buildConfig) default: - return nil, fmt.Errorf("unsupported agent kind: %s. Supported kinds are: hosted", agentDef.Kind) + return nil, fmt.Errorf("unsupported agent kind: %s. Supported kinds are: hosted, managed", agentDef.Kind) } } @@ -447,6 +450,51 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB hostedAgent.AgentEndpoint, hostedAgent.AgentCard) } +// CreateManagedAgentAPIRequest converts a ManagedAgent YAML definition into the +// API CreateAgentRequest expected by the Foundry managed-agent endpoint. +// +// Managed agents are simpler than hosted agents — the customer only declares +// model + instructions (plus optional skills/policies). The platform manages +// the Brain+Hand sandbox, so no image/cpu/memory fields are required from the +// customer for the minimum case. +func CreateManagedAgentAPIRequest( + managedAgent ManagedAgent, + buildConfig *AgentBuildConfig, +) (*agent_api.CreateAgentRequest, error) { + if strings.TrimSpace(managedAgent.Model) == "" { + return nil, fmt.Errorf("managed agent requires a non-empty model") + } + if strings.TrimSpace(managedAgent.Instructions) == "" { + return nil, fmt.Errorf("managed agent requires non-empty instructions") + } + + managedDef := agent_api.ManagedAgentDefinition{ + AgentDefinition: agent_api.AgentDefinition{ + Kind: agent_api.AgentKindManaged, + RaiConfig: mapRaiConfig(managedAgent.Policies), + }, + Model: managedAgent.Model, + Harness: agent_api.ManagedAgentHarnessGitHubCopilot, + Instructions: managedAgent.Instructions, + } + + if len(managedAgent.Skills) > 0 { + managedDef.Skills = append([]string(nil), managedAgent.Skills...) + } + + // Build-time environment variables (if supplied) get carried into the + // managed environment block so the Hand sandbox can read them. + if buildConfig != nil && len(buildConfig.EnvironmentVariables) > 0 { + managedDef.Environment = &agent_api.ManagedEnvironment{ + EnvironmentVariables: maps.Clone(buildConfig.EnvironmentVariables), + } + } + + // Managed agents do not have endpoint or agent-card customization at the + // YAML layer today, so pass nil for both. + return createAgentAPIRequest(managedAgent.AgentDefinition, managedDef, nil, nil) +} + // createAgentAPIRequest is a helper function to create the final request with common fields. // The optional agentEndpoint and agentCard parameters are mapped to the corresponding // request-level fields when non-nil. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go index 2a9dd971239..ac6a528b9f2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go @@ -115,6 +115,14 @@ func ExtractAgentDefinition(manifestYamlContent []byte) (any, error) { return nil, fmt.Errorf("failed to unmarshal to ContainerAgent: %w", err) } + agent.AgentDefinition = agentDef + return agent, nil + case AgentKindManaged: + var agent ManagedAgent + if err := yaml.Unmarshal(templateBytes, &agent); err != nil { + return nil, fmt.Errorf("failed to unmarshal to ManagedAgent: %w", err) + } + agent.AgentDefinition = agentDef return agent, nil } @@ -418,6 +426,35 @@ func ValidateAgentDefinition(templateBytes []byte) error { } else { errors = append(errors, fmt.Sprintf("failed to unmarshal to Workflow: %v", err)) } + case AgentKindManaged: + var agent ManagedAgent + if err := yaml.Unmarshal(templateBytes, &agent); err == nil { + if strings.TrimSpace(agent.Model) == "" { + errors = append(errors, "template.model is required for managed agents") + } + if strings.TrimSpace(agent.Instructions) == "" { + errors = append(errors, "template.instructions is required for managed agents") + } + for i, policy := range agent.Policies { + switch policy.Type { + case PolicyTypeRai: + if policy.RaiPolicyName == "" { + errors = append(errors, fmt.Sprintf( + "policies[%d] of type '%s' requires a policy name (rai_policy_name)", + i, policy.Type)) + } + case "": + errors = append(errors, fmt.Sprintf( + "policies[%d] requires a type", i)) + default: + errors = append(errors, fmt.Sprintf( + "policies[%d] has an unsupported type '%s' (supported: %s)", + i, policy.Type, PolicyTypeRai)) + } + } + } else { + errors = append(errors, fmt.Sprintf("failed to unmarshal to ManagedAgent: %v", err)) + } } } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index 8038d30ba06..7893c66842b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -16,6 +16,11 @@ type AgentKind string const ( AgentKindHosted AgentKind = "hosted" AgentKindWorkflow AgentKind = "workflow" + // AgentKindManaged is the Foundry "managed" agent kind backed by the + // Prompt Execution Service (PES) Brain+Hand sandbox architecture. + // Lifecycle and response APIs live behind the same data-plane routes + // as the other Foundry kinds, with a "kind": "managed" discriminator. + AgentKindManaged AgentKind = "managed" ) // IsValidAgentKind checks if the provided AgentKind is valid @@ -28,6 +33,7 @@ func ValidAgentKinds() []AgentKind { return []AgentKind{ AgentKindHosted, AgentKindWorkflow, + AgentKindManaged, } } @@ -231,6 +237,29 @@ type ContainerAgent struct { Policies []Policy `json:"policies,omitempty" yaml:"policies,omitempty"` } +// ManagedAgent represents a Foundry "managed" agent — a PES (Prompt Execution +// Service) backed agent whose Brain+Hand sandbox is provisioned by the +// platform on demand. The customer declares the model and instructions; the +// platform manages the runtime, lifecycle, and orchestration. +// +// Unlike ContainerAgent, the customer does not provide a container image or +// code; the only required fields are Model and Instructions. +type ManagedAgent struct { + AgentDefinition `json:",inline" yaml:",inline"` + + // Model is the model deployment name to use for this agent (e.g. "gpt-4.1-mini"). + Model string `json:"model" yaml:"model"` + + // Instructions is the system/developer message inserted into the model's context. + Instructions string `json:"instructions" yaml:"instructions"` + + // Skills is an optional list of Foundry skill names attached to the agent. + Skills []string `json:"skills,omitempty" yaml:"skills,omitempty"` + + // Policies is an optional list of governance policies (e.g. RAI). + Policies []Policy `json:"policies,omitempty" yaml:"policies,omitempty"` +} + // AgentManifest The following represents a manifest that can be used to create agents dynamically. // It includes parameters that can be used to configure the agent's behavior. // These parameters include values that can be used as publisher parameters that can diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config.go b/cli/azd/extensions/azure.ai.agents/internal/project/config.go index b004f8ff62f..2ebc1ca37f7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -49,6 +49,12 @@ type ServiceTargetAgentConfig struct { Toolboxes []Toolbox `json:"toolboxes,omitempty"` Connections []Connection `json:"connections,omitempty"` StartupCommand string `json:"startupCommand,omitempty"` + // PromptAgent holds the harness connection details for a "prompt" + // (kind=managed) agent service. It is only populated for prompt agents; + // hosted/workflow agents leave it nil. The harness has no container/code + // to build, so prompt-agent services carry their entire deploy target in + // this block instead of a Docker/code configuration. + PromptAgent *PromptAgentSettings `json:"promptAgent,omitempty"` } // ContainerSettings provides container configuration for the Azure AI Service target diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client.go new file mode 100644 index 00000000000..b7d288b547c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client.go @@ -0,0 +1,339 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "fmt" + "net/url" + "os" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" +) + +// Environment-variable overrides for the prompt-agent (managed) harness +// client. When set, these take precedence over the corresponding fields in +// the azure.yaml service config so developers can temporarily retarget the +// harness without editing the project file. +const ( + PromptBaseURLEnvVar = "AZD_MANAGED_AGENT_BASE_URL" + PromptSubscriptionEnvVar = "AZD_MANAGED_AGENT_SUBSCRIPTION_ID" + PromptResourceGroupEnvVar = "AZD_MANAGED_AGENT_RESOURCE_GROUP" + PromptWorkspaceEnvVar = "AZD_MANAGED_AGENT_WORKSPACE" + PromptProjectEndpointEnvVar = "AZD_MANAGED_AGENT_PROJECT_ENDPOINT" + PromptAPIVersionEnvVar = "AZD_MANAGED_AGENT_API_VERSION" + PromptModelEndpointEnvVar = "AZD_MANAGED_AGENT_MODEL_ENDPOINT" + // PromptNoAuthEnvVar, when truthy, skips attaching a bearer token to + // harness requests. Use it only against a harness that runs with auth + // fully bypassed; by default a cognitive-services token is attached. + PromptNoAuthEnvVar = "AZD_MANAGED_AGENT_NO_AUTH" +) + +// DefaultPromptBaseURL is the public managed prompt-agent control plane base +// URL prefix. The deploy path appends / (from AZURE_LOCATION) when +// this default is still in use. +const DefaultPromptBaseURL = "https://ai.azure.com/api" + +// Default ARM workspace tuple placeholders used when prompt init runs in +// non-guided mode. Guided init and env overlays replace these with real +// provisioned values. +const ( + DefaultPromptSubscriptionID = "00000000-0000-0000-0000-000000000001" + DefaultPromptResourceGroup = "test-rg" + DefaultPromptWorkspace = "test-ws" +) + +// DefaultPromptAPIVersion is the api-version query parameter sent on every +// prompt-agent request. +const DefaultPromptAPIVersion = "2025-05-15-preview" + +// DefaultPromptModelEndpoint is the model gateway the harness calls to reach +// the LLM. It is sent on invoke (Responses) requests via the x-model-endpoint +// header. +const DefaultPromptModelEndpoint = "https://va-dev-fdp-resource.services.ai.azure.com" + +// PromptAgentSettings captures the harness connection details for a prompt +// (kind=managed) agent. It is stored in the azure.yaml service config block +// (ServiceTargetAgentConfig.PromptAgent) and resolved at deploy/invoke time. +type PromptAgentSettings struct { + // BaseURL is the harness origin (scheme + host [+ port]). Required. + BaseURL string `json:"baseUrl"` + + // SubscriptionID is the Azure subscription containing the workspace. + SubscriptionID string `json:"subscriptionId"` + + // ResourceGroup is the Azure resource group containing the workspace. + ResourceGroup string `json:"resourceGroup"` + + // Workspace is the Azure ML / Foundry workspace name. + Workspace string `json:"workspace"` + + // ProjectEndpoint is the Foundry project data-plane root + // (https://.services.ai.azure.com/api/projects/). When set, + // it is the authoritative routing target for ALL managed agent operations + // (CRUD and Responses) and supersedes the legacy workspace tuple. It is + // populated from the interactive init selection or, in --no-prompt flows, + // from AZURE_AI_PROJECT_ENDPOINT in the azd environment. + ProjectEndpoint string `json:"projectEndpoint,omitempty"` + + // APIVersion is the api-version query parameter sent on every request. + // Defaults to DefaultPromptAPIVersion when empty. + APIVersion string `json:"apiVersion,omitempty"` + + // ModelEndpoint is the model gateway the harness calls to reach the LLM. + // Sent on invoke requests via the x-model-endpoint header. Defaults to + // DefaultPromptModelEndpoint when empty. + ModelEndpoint string `json:"modelEndpoint,omitempty"` +} + +// DefaultPromptAgentSettings returns settings populated with public managed +// prompt-agent defaults plus placeholder workspace tuple values used by +// non-guided init. +func DefaultPromptAgentSettings() PromptAgentSettings { + return PromptAgentSettings{ + BaseURL: DefaultPromptBaseURL, + SubscriptionID: DefaultPromptSubscriptionID, + ResourceGroup: DefaultPromptResourceGroup, + Workspace: DefaultPromptWorkspace, + APIVersion: DefaultPromptAPIVersion, + ModelEndpoint: DefaultPromptModelEndpoint, + } +} + +// Validate reports a typed error when any required field is empty. +func (s *PromptAgentSettings) Validate() error { + if s == nil { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "prompt agent settings are not configured", + "re-run `azd ai agent init` to scaffold the prompt agent service", + ) + } + var missing []string + if strings.TrimSpace(s.BaseURL) == "" { + missing = append(missing, "baseUrl") + } + if strings.TrimSpace(s.SubscriptionID) == "" { + missing = append(missing, "subscriptionId") + } + if strings.TrimSpace(s.ResourceGroup) == "" { + missing = append(missing, "resourceGroup") + } + if strings.TrimSpace(s.Workspace) == "" { + missing = append(missing, "workspace") + } + if len(missing) > 0 { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("prompt agent config is missing required fields: %s", strings.Join(missing, ", ")), + "edit the promptAgent block in azure.yaml, or re-run `azd ai agent init`", + ) + } + return nil +} + +// EffectiveAPIVersion returns the configured api-version, falling back to the +// package-level default when empty. +func (s *PromptAgentSettings) EffectiveAPIVersion() string { + if s == nil || strings.TrimSpace(s.APIVersion) == "" { + return DefaultPromptAPIVersion + } + return strings.TrimSpace(s.APIVersion) +} + +// EffectiveModelEndpoint returns the configured model endpoint, falling back +// to the package-level default when empty. +func (s *PromptAgentSettings) EffectiveModelEndpoint() string { + if s == nil || strings.TrimSpace(s.ModelEndpoint) == "" { + return DefaultPromptModelEndpoint + } + return s.ModelEndpoint +} + +// ApplyEnvOverrides updates any non-empty environment variables into the +// settings. Env vars trump stored values so a developer can temporarily +// retarget the harness without editing azure.yaml. +func (s *PromptAgentSettings) ApplyEnvOverrides() { + if s == nil { + return + } + if v := strings.TrimSpace(os.Getenv(PromptBaseURLEnvVar)); v != "" { + s.BaseURL = v + } + if v := strings.TrimSpace(os.Getenv(PromptSubscriptionEnvVar)); v != "" { + s.SubscriptionID = v + } + if v := strings.TrimSpace(os.Getenv(PromptResourceGroupEnvVar)); v != "" { + s.ResourceGroup = v + } + if v := strings.TrimSpace(os.Getenv(PromptWorkspaceEnvVar)); v != "" { + s.Workspace = v + } + if v := strings.TrimSpace(os.Getenv(PromptProjectEndpointEnvVar)); v != "" { + s.ProjectEndpoint = v + } + if v := strings.TrimSpace(os.Getenv(PromptAPIVersionEnvVar)); v != "" { + s.APIVersion = v + } + if v := strings.TrimSpace(os.Getenv(PromptModelEndpointEnvVar)); v != "" { + s.ModelEndpoint = v + } +} + +// NewPromptAgentClient constructs a ManagedAgentClient from the given prompt +// settings. Environment overrides are applied first, then the settings are +// validated. Set AZD_MANAGED_AGENT_NO_AUTH=true to skip attaching a bearer +// token. +// +// Routing target: +// - When ProjectEndpoint is set, all operations target the Foundry project +// data-plane: https://.services.ai.azure.com/api/projects//agents?api-version=v1 +// - Otherwise it falls back to the legacy workspace-rooted management route. +func NewPromptAgentClient(settings *PromptAgentSettings) (*agent_api.ManagedAgentClient, error) { + if settings == nil { + return nil, fmt.Errorf("NewPromptAgentClient: settings is nil") + } + settings.ApplyEnvOverrides() + if err := settings.Validate(); err != nil { + return nil, err + } + + baseURL := settings.BaseURL + var prefix string + if pe := strings.TrimSpace(settings.ProjectEndpoint); pe != "" { + b, p, err := agent_api.SplitProjectEndpoint(pe) + if err != nil { + return nil, err + } + baseURL, prefix = b, p + } else { + p, err := agent_api.BuildWorkspaceRoutePrefix( + settings.SubscriptionID, settings.ResourceGroup, settings.Workspace, + ) + if err != nil { + return nil, fmt.Errorf("building workspace route prefix: %w", err) + } + prefix = p + } + + return agent_api.NewManagedAgentClient(agent_api.ManagedAgentClientOptions{ + BaseURL: baseURL, + RoutePrefix: prefix, + Credential: promptCredential(), + Scopes: promptScopesForBaseURL(baseURL), + }) +} + +// promptScopesForBaseURL selects auth scopes by target endpoint. +// +// Public endpoints use audience-specific tokens: +// - ai.azure.com and .api.azureml.ms use AI audience tokens. +// - management.azure.com uses ARM audience tokens. +// +// Local/custom harness endpoints continue to use cognitive-services scope. +func promptScopesForBaseURL(baseURL string) []string { + parsed, err := url.Parse(strings.TrimSpace(baseURL)) + if err == nil { + host := strings.ToLower(parsed.Hostname()) + if strings.HasSuffix(host, "ai.azure.com") || strings.HasSuffix(host, ".api.azureml.ms") { + return []string{"https://ai.azure.com/.default"} + } + if strings.HasSuffix(host, "management.azure.com") { + return []string{"https://management.azure.com/.default"} + } + } + + return []string{"https://cognitiveservices.azure.com/.default"} +} + +// promptCredential returns the bearer-token credential to attach to harness +// requests, or nil when AZD_MANAGED_AGENT_NO_AUTH is truthy. +// +// Credential-construction failures are surfaced as nil so the underlying HTTP +// error from the service (401/403) becomes the user-visible failure mode — +// that error is more actionable than a generic "failed to create credential" +// wrap. +func promptCredential() azcore.TokenCredential { + if isTruthyEnvValue(os.Getenv(PromptNoAuthEnvVar)) { + return nil + } + c, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{}, + ) + if err == nil { + return c + } + + // Fall back to Azure CLI tokens when azd credential construction is not + // available in the current process context. + azCred, azErr := azidentity.NewAzureCLICredential(&azidentity.AzureCLICredentialOptions{}) + if azErr == nil { + return azCred + } + + return nil +} + +// isTruthyEnvValue reports whether an environment-variable value should be +// treated as "on" (true/1/yes/on, case-insensitive). +func isTruthyEnvValue(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "1", "yes", "on": + return true + default: + return false + } +} + +// OverlayAzdProjectEnv fills any harness target field still at its package +// default placeholder from the provisioned azd project environment values. +// +// Real values resolved at init time (a user-selected Foundry project) are +// non-default and are preserved. This makes the "create a new Foundry project" +// init path work end-to-end: `azd up` provisions the project and writes the +// AZURE_* env vars, and the deploy then targets that provisioned project. +// +// The overlay is atomic on the presence of a resolved project: when the azd +// environment has no AZURE_AI_PROJECT_NAME (e.g. a scaffold that was never +// provisioned), nothing is changed and placeholder defaults are preserved. +// env is the azd environment key/value map; missing keys are ignored. +func (s *PromptAgentSettings) OverlayAzdProjectEnv(env map[string]string) { + if s == nil || env == nil { + return + } + if strings.TrimSpace(s.BaseURL) == DefaultPromptBaseURL { + if location := strings.ToLower(strings.TrimSpace(env["AZURE_LOCATION"])); location != "" { + s.BaseURL = fmt.Sprintf("%s/%s", DefaultPromptBaseURL, location) + } + } + // Gate on a resolved/provisioned project. Without one there is nothing to + // overlay and placeholder tuple values must be preserved. + if strings.TrimSpace(env["AZURE_AI_PROJECT_NAME"]) == "" { + return + } + if strings.TrimSpace(s.SubscriptionID) == "" || s.SubscriptionID == DefaultPromptSubscriptionID { + if v := strings.TrimSpace(env["AZURE_SUBSCRIPTION_ID"]); v != "" { + s.SubscriptionID = v + } + } + if strings.TrimSpace(s.ResourceGroup) == "" || s.ResourceGroup == DefaultPromptResourceGroup { + if v := strings.TrimSpace(env["AZURE_RESOURCE_GROUP"]); v != "" { + s.ResourceGroup = v + } + } + if strings.TrimSpace(s.Workspace) == "" || s.Workspace == DefaultPromptWorkspace { + if v := strings.TrimSpace(env["AZURE_AI_PROJECT_NAME"]); v != "" { + s.Workspace = v + } + } + if strings.TrimSpace(s.ModelEndpoint) == "" || s.ModelEndpoint == DefaultPromptModelEndpoint { + if v := strings.TrimSpace(env["AZURE_AI_ACCOUNT_NAME"]); v != "" { + s.ModelEndpoint = fmt.Sprintf("https://%s.services.ai.azure.com", v) + } + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go new file mode 100644 index 00000000000..3b1ffa5f2ec --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go @@ -0,0 +1,371 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// TestDefaultPromptAgentSettings_PublicDefaults asserts the defaults point at +// the public managed prompt-agent endpoint with placeholder workspace tuple. +func TestDefaultPromptAgentSettings_PublicDefaults(t *testing.T) { + s := DefaultPromptAgentSettings() + if s.BaseURL != DefaultPromptBaseURL { + t.Errorf("BaseURL: got %q, want %q", s.BaseURL, DefaultPromptBaseURL) + } + if s.SubscriptionID != DefaultPromptSubscriptionID { + t.Errorf("SubscriptionID: got %q, want %q", s.SubscriptionID, DefaultPromptSubscriptionID) + } + if s.ResourceGroup != DefaultPromptResourceGroup { + t.Errorf("ResourceGroup: got %q, want %q", s.ResourceGroup, DefaultPromptResourceGroup) + } + if s.Workspace != DefaultPromptWorkspace { + t.Errorf("Workspace: got %q, want %q", s.Workspace, DefaultPromptWorkspace) + } + if s.EffectiveAPIVersion() != DefaultPromptAPIVersion { + t.Errorf("api-version: got %q, want %q", s.EffectiveAPIVersion(), DefaultPromptAPIVersion) + } + if s.EffectiveModelEndpoint() != DefaultPromptModelEndpoint { + t.Errorf("model endpoint: got %q, want %q", s.EffectiveModelEndpoint(), DefaultPromptModelEndpoint) + } + if err := s.Validate(); err != nil { + t.Errorf("default settings should validate: %v", err) + } +} + +// TestPromptAgentSettings_Validate_MissingFields asserts each required field is +// reported when empty. +func TestPromptAgentSettings_Validate_MissingFields(t *testing.T) { + cases := map[string]PromptAgentSettings{ + "missing baseUrl": {SubscriptionID: "s", ResourceGroup: "r", Workspace: "w"}, + "missing subscriptionId": {BaseURL: "https://ai.azure.com", ResourceGroup: "r", Workspace: "w"}, + "missing resourceGroup": {BaseURL: "https://ai.azure.com", SubscriptionID: "s", Workspace: "w"}, + "missing workspace": {BaseURL: "https://ai.azure.com", SubscriptionID: "s", ResourceGroup: "r"}, + } + for name, s := range cases { + t.Run(name, func(t *testing.T) { + if err := s.Validate(); err == nil { + t.Fatalf("expected validation error for %s", name) + } + }) + } +} + +// TestPromptAgentSettings_EffectiveDefaults asserts the effective getters fall +// back to package defaults when unset and honor explicit values. +func TestPromptAgentSettings_EffectiveDefaults(t *testing.T) { + s := &PromptAgentSettings{} + if s.EffectiveAPIVersion() != DefaultPromptAPIVersion { + t.Errorf("api-version fallback: got %q", s.EffectiveAPIVersion()) + } + if s.EffectiveModelEndpoint() != DefaultPromptModelEndpoint { + t.Errorf("model endpoint fallback: got %q", s.EffectiveModelEndpoint()) + } + s.APIVersion = "v2" + s.ModelEndpoint = "https://custom" + if s.EffectiveAPIVersion() != "v2" { + t.Errorf("api-version: got %q, want v2", s.EffectiveAPIVersion()) + } + if s.EffectiveModelEndpoint() != "https://custom" { + t.Errorf("model endpoint: got %q, want https://custom", s.EffectiveModelEndpoint()) + } +} + +// TestPromptAgentSettings_ApplyEnvOverrides asserts environment variables take +// precedence over stored values. +func TestPromptAgentSettings_ApplyEnvOverrides(t *testing.T) { + s := DefaultPromptAgentSettings() + t.Setenv(PromptBaseURLEnvVar, "http://localhost:9999") + t.Setenv(PromptSubscriptionEnvVar, "sub-override") + t.Setenv(PromptResourceGroupEnvVar, "rg-override") + t.Setenv(PromptWorkspaceEnvVar, "ws-override") + t.Setenv(PromptAPIVersionEnvVar, "v9") + t.Setenv(PromptModelEndpointEnvVar, "https://model-override") + + s.ApplyEnvOverrides() + + if s.BaseURL != "http://localhost:9999" { + t.Errorf("BaseURL override: got %q", s.BaseURL) + } + if s.SubscriptionID != "sub-override" { + t.Errorf("SubscriptionID override: got %q", s.SubscriptionID) + } + if s.ResourceGroup != "rg-override" { + t.Errorf("ResourceGroup override: got %q", s.ResourceGroup) + } + if s.Workspace != "ws-override" { + t.Errorf("Workspace override: got %q", s.Workspace) + } + if s.EffectiveAPIVersion() != "v9" { + t.Errorf("APIVersion override: got %q", s.EffectiveAPIVersion()) + } + if s.EffectiveModelEndpoint() != "https://model-override" { + t.Errorf("ModelEndpoint override: got %q", s.EffectiveModelEndpoint()) + } +} + +// TestNewPromptAgentClient_BuildsClient asserts a client builds from valid +// settings (no-auth path to avoid requiring an Azure login in tests). +func TestNewPromptAgentClient_BuildsClient(t *testing.T) { + t.Setenv(PromptNoAuthEnvVar, "true") + s := DefaultPromptAgentSettings() + client, err := NewPromptAgentClient(&s) + if err != nil { + t.Fatalf("NewPromptAgentClient: %v", err) + } + if client == nil { + t.Fatal("expected non-nil client") + } +} + +// TestPromptAgentResponsesEndpoint asserts the workspace-rooted Responses URL is +// assembled correctly. +func TestPromptAgentResponsesEndpoint(t *testing.T) { + s := PromptAgentSettings{ + BaseURL: "http://localhost:5000", + SubscriptionID: "sub-1", + ResourceGroup: "rg-x", + Workspace: "ws-y", + APIVersion: "v1", + } + got := promptAgentResponsesEndpoint(&s) + want := "http://localhost:5000/agents/v2.0/subscriptions/sub-1/resourceGroups/rg-x/" + + "providers/Microsoft.MachineLearningServices/workspaces/ws-y/openai/responses?api-version=v1" + if got != want { + t.Errorf("endpoint:\n got %q\nwant %q", got, want) + } +} + +// TestPromptAgentResponsesEndpoint_ProjectEndpoint asserts the Responses URL is +// built off the Foundry project data-plane endpoint when one is configured. +func TestPromptAgentResponsesEndpoint_ProjectEndpoint(t *testing.T) { + s := PromptAgentSettings{ + ProjectEndpoint: "https://acct.services.ai.azure.com/api/projects/proj", + APIVersion: "v1", + } + got := promptAgentResponsesEndpoint(&s) + want := "https://acct.services.ai.azure.com/api/projects/proj/openai/v1/responses" + if got != want { + t.Errorf("endpoint:\n got %q\nwant %q", got, want) + } +} + +// TestOverlayAzdProjectEnv_FillsDefaultsOnly asserts that only fields still at +// their package default are overlaid from the azd environment, and real values +// resolved at init time are preserved. +func TestOverlayAzdProjectEnv_FillsDefaultsOnly(t *testing.T) { + env := map[string]string{ + "AZURE_SUBSCRIPTION_ID": "real-sub", + "AZURE_RESOURCE_GROUP": "real-rg", + "AZURE_AI_PROJECT_NAME": "real-proj", + "AZURE_AI_ACCOUNT_NAME": "myacct", + } + + t.Run("defaults are filled from env", func(t *testing.T) { + s := DefaultPromptAgentSettings() + s.OverlayAzdProjectEnv(env) + if s.SubscriptionID != "real-sub" { + t.Errorf("SubscriptionID: got %q", s.SubscriptionID) + } + if s.ResourceGroup != "real-rg" { + t.Errorf("ResourceGroup: got %q", s.ResourceGroup) + } + if s.Workspace != "real-proj" { + t.Errorf("Workspace: got %q", s.Workspace) + } + if s.ModelEndpoint != "https://myacct.services.ai.azure.com" { + t.Errorf("ModelEndpoint: got %q", s.ModelEndpoint) + } + }) + + t.Run("non-default values are preserved", func(t *testing.T) { + s := PromptAgentSettings{ + BaseURL: "https://harness.example", + SubscriptionID: "chosen-sub", + ResourceGroup: "chosen-rg", + Workspace: "chosen-ws", + ModelEndpoint: "https://chosen.services.ai.azure.com", + } + s.OverlayAzdProjectEnv(env) + if s.SubscriptionID != "chosen-sub" || s.ResourceGroup != "chosen-rg" || + s.Workspace != "chosen-ws" || s.ModelEndpoint != "https://chosen.services.ai.azure.com" { + t.Errorf("non-default values should be preserved, got %+v", s) + } + }) + + t.Run("nil env is a no-op", func(t *testing.T) { + s := DefaultPromptAgentSettings() + s.OverlayAzdProjectEnv(nil) + if s.Workspace != DefaultPromptWorkspace { + t.Errorf("nil env should not change settings") + } + }) + + t.Run("env without a project name is a no-op", func(t *testing.T) { + // No AZURE_AI_PROJECT_NAME means no provisioned project — the local-dev + // fake tuple must be preserved even if a subscription id leaks in. + s := DefaultPromptAgentSettings() + s.OverlayAzdProjectEnv(map[string]string{"AZURE_SUBSCRIPTION_ID": "leaked-sub"}) + if s.SubscriptionID != DefaultPromptSubscriptionID || s.Workspace != DefaultPromptWorkspace { + t.Errorf("settings should be untouched without a project name, got %+v", s) + } + }) +} + +func TestOverlayPromptSettingsFromProjectResourceID(t *testing.T) { + tests := []struct { + name string + settings PromptAgentSettings + env map[string]string + wantApplied bool + wantErr bool + wantCode string + wantSubscriptionID string + wantResourceGroup string + wantWorkspace string + wantModelEndpoint string + }{ + { + name: "applies from valid project id", + settings: DefaultPromptAgentSettings(), + env: map[string]string{ + "AZURE_AI_PROJECT_ID": "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.CognitiveServices/accounts/acct-1/projects/proj-1", + }, + wantApplied: true, + wantSubscriptionID: "sub-1", + wantResourceGroup: "rg-1", + wantWorkspace: "acct-1@proj-1@AML", + wantModelEndpoint: "https://acct-1.services.ai.azure.com", + }, + { + name: "keeps explicit model endpoint", + settings: PromptAgentSettings{ + BaseURL: DefaultPromptBaseURL, + SubscriptionID: "custom-sub", + ResourceGroup: "custom-rg", + Workspace: "custom-ws", + ModelEndpoint: "https://custom.services.ai.azure.com", + }, + env: map[string]string{ + "AZURE_AI_PROJECT_ID": "/subscriptions/sub-2/resourceGroups/rg-2/providers/Microsoft.CognitiveServices/accounts/acct-2/projects/proj-2", + }, + wantApplied: true, + wantSubscriptionID: "sub-2", + wantResourceGroup: "rg-2", + wantWorkspace: "acct-2@proj-2@AML", + wantModelEndpoint: "https://custom.services.ai.azure.com", + }, + { + name: "no project id means no-op", + settings: DefaultPromptAgentSettings(), + env: map[string]string{}, + wantApplied: false, + wantWorkspace: DefaultPromptWorkspace, + }, + { + name: "invalid project id returns validation error", + settings: DefaultPromptAgentSettings(), + env: map[string]string{ + "AZURE_AI_PROJECT_ID": "not-a-resource-id", + }, + wantErr: true, + wantCode: "invalid_ai_project_id", + }, + { + name: "non project resource id returns validation error", + settings: DefaultPromptAgentSettings(), + env: map[string]string{ + "AZURE_AI_PROJECT_ID": "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.CognitiveServices/accounts/acct-1", + }, + wantErr: true, + wantCode: "invalid_ai_project_id", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := tt.settings + applied, err := overlayPromptSettingsFromProjectResourceID(&s, tt.env) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error") + } + localErr, ok := err.(*azdext.LocalError) + if !ok { + t.Fatalf("expected *azdext.LocalError, got %T", err) + } + if tt.wantCode != "" && localErr.Code != tt.wantCode { + t.Fatalf("error code: got %q, want %q", localErr.Code, tt.wantCode) + } + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if applied != tt.wantApplied { + t.Fatalf("applied: got %t, want %t", applied, tt.wantApplied) + } + if tt.wantSubscriptionID != "" && s.SubscriptionID != tt.wantSubscriptionID { + t.Fatalf("SubscriptionID: got %q, want %q", s.SubscriptionID, tt.wantSubscriptionID) + } + if tt.wantResourceGroup != "" && s.ResourceGroup != tt.wantResourceGroup { + t.Fatalf("ResourceGroup: got %q, want %q", s.ResourceGroup, tt.wantResourceGroup) + } + if tt.wantWorkspace != "" && s.Workspace != tt.wantWorkspace { + t.Fatalf("Workspace: got %q, want %q", s.Workspace, tt.wantWorkspace) + } + if tt.wantModelEndpoint != "" && s.ModelEndpoint != tt.wantModelEndpoint { + t.Fatalf("ModelEndpoint: got %q, want %q", s.ModelEndpoint, tt.wantModelEndpoint) + } + }) + } +} + +// TestResolvePromptTargetFromEnv_ProjectEndpoint asserts that the Foundry +// project data-plane endpoint is resolved (config first, env fallback), that +// the api-version is normalized to v1, and that the model endpoint is derived +// from the account host. +func TestResolvePromptTargetFromEnv_ProjectEndpoint(t *testing.T) { + t.Run("from environment when config is empty", func(t *testing.T) { + s := DefaultPromptAgentSettings() + env := map[string]string{ + "AZURE_AI_PROJECT_NAME": "proj-1", + "AZURE_AI_PROJECT_ENDPOINT": "https://acct-1.services.ai.azure.com/api/projects/proj-1", + } + applied, err := ResolvePromptTargetFromEnv(&s, env) + if err != nil { + t.Fatalf("ResolvePromptTargetFromEnv: %v", err) + } + if !applied { + t.Fatalf("expected project-scoped target to be applied") + } + if s.ProjectEndpoint != "https://acct-1.services.ai.azure.com/api/projects/proj-1" { + t.Errorf("ProjectEndpoint: got %q", s.ProjectEndpoint) + } + if s.EffectiveAPIVersion() != ProjectEndpointAPIVersion { + t.Errorf("APIVersion: got %q, want %q", s.EffectiveAPIVersion(), ProjectEndpointAPIVersion) + } + if s.ModelEndpoint != "https://acct-1.services.ai.azure.com" { + t.Errorf("ModelEndpoint: got %q", s.ModelEndpoint) + } + }) + + t.Run("config value takes precedence over environment", func(t *testing.T) { + s := DefaultPromptAgentSettings() + s.ProjectEndpoint = "https://config-acct.services.ai.azure.com/api/projects/config-proj" + env := map[string]string{ + "AZURE_AI_PROJECT_NAME": "proj-1", + "AZURE_AI_PROJECT_ENDPOINT": "https://env-acct.services.ai.azure.com/api/projects/env-proj", + } + if _, err := ResolvePromptTargetFromEnv(&s, env); err != nil { + t.Fatalf("ResolvePromptTargetFromEnv: %v", err) + } + if s.ProjectEndpoint != "https://config-acct.services.ai.azure.com/api/projects/config-proj" { + t.Errorf("ProjectEndpoint should keep config value, got %q", s.ProjectEndpoint) + } + }) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index ed621e2c42d..b51fcb4e199 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -216,6 +216,15 @@ func (p *AgentServiceTargetProvider) Initialize(ctx context.Context, serviceConf } p.env = currEnv.Environment + // Prompt (kind=managed) agents target the managed harness, not an ARM + // Foundry project. They self-authenticate via the harness client and carry + // their entire deploy target in the service config, so skip the + // subscription/tenant/credential resolution the hosted path needs. + if serviceIsPromptAgent(serviceConfig) { + fmt.Fprintf(os.Stderr, "Project path: %s, Service path: %s\n", proj.Project.Path, fullPath) + return p.resolveAgentDefinitionPath(proj.Project.Path, servicePath, fullPath) + } + // Get subscription ID from environment resp, err := azdEnvClient.GetValue(ctx, &azdext.GetEnvRequest{ EnvName: p.env.Name, @@ -264,6 +273,15 @@ func (p *AgentServiceTargetProvider) Initialize(ctx context.Context, serviceConf fmt.Fprintf(os.Stderr, "Project path: %s, Service path: %s\n", proj.Project.Path, fullPath) + return p.resolveAgentDefinitionPath(proj.Project.Path, servicePath, fullPath) +} + +// resolveAgentDefinitionPath locates the agent definition (agent.yaml/agent.yml +// or the AGENT_DEFINITION_PATH override) for the service and stores it on the +// provider. It is shared by the hosted and prompt-agent Initialize paths. +func (p *AgentServiceTargetProvider) resolveAgentDefinitionPath( + projectPath, servicePath, fullPath string, +) error { // Check if user has specified agent definition path via environment variable if envPath := os.Getenv("AGENT_DEFINITION_PATH"); envPath != "" { // Verify the file exists and has correct extension @@ -291,19 +309,19 @@ func (p *AgentServiceTargetProvider) Initialize(ctx context.Context, serviceConf } // Look for agent.yaml or agent.yml in the service directory root - agentYamlPath, err := paths.JoinAllowRoot(proj.Project.Path, servicePath, "agent.yaml") + agentYamlPath, err := paths.JoinAllowRoot(projectPath, servicePath, "agent.yaml") if err != nil { return exterrors.Validation( exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("invalid agent definition path for %s: %s", serviceConfig.Name, err), + fmt.Sprintf("invalid agent definition path: %s", err), "update azure.yaml so the agent definition stays within the project directory", ) } - agentYmlPath, err := paths.JoinAllowRoot(proj.Project.Path, servicePath, "agent.yml") + agentYmlPath, err := paths.JoinAllowRoot(projectPath, servicePath, "agent.yml") if err != nil { return exterrors.Validation( exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("invalid agent definition path for %s: %s", serviceConfig.Name, err), + fmt.Sprintf("invalid agent definition path: %s", err), "update azure.yaml so the agent definition stays within the project directory", ) } @@ -340,6 +358,16 @@ func (p *AgentServiceTargetProvider) Endpoints( serviceConfig *azdext.ServiceConfig, targetResource *azdext.TargetResource, ) ([]string, error) { + // Prompt agents expose a single workspace-rooted Responses endpoint on the + // harness. Build it from the service config rather than azd env vars. + if p.isPromptAgentService() { + settings, err := p.promptAgentSettings() + if err != nil { + return nil, err + } + return []string{promptAgentResponsesEndpoint(settings)}, nil + } + // Get all environment values resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ Name: p.env.Name, @@ -405,6 +433,27 @@ func (p *AgentServiceTargetProvider) GetTargetResource( serviceConfig *azdext.ServiceConfig, defaultResolver func() (*azdext.TargetResource, error), ) (*azdext.TargetResource, error) { + // Prompt agents target the managed harness, not an ARM Foundry project. + // Synthesize a target resource from the harness workspace tuple so core + // azd has something to display without resolving a CognitiveServices + // project that does not exist for this flow. + if p.isPromptAgentService() { + settings, err := p.promptAgentSettings() + if err != nil { + return nil, err + } + return &azdext.TargetResource{ + SubscriptionId: settings.SubscriptionID, + ResourceGroupName: settings.ResourceGroup, + ResourceName: settings.Workspace, + ResourceType: "Microsoft.MachineLearningServices/workspaces", + Metadata: map[string]string{ + "workspace": settings.Workspace, + "baseUrl": settings.BaseURL, + }, + }, nil + } + // Ensure Foundry project is loaded if err := p.ensureFoundryProject(ctx); err != nil { return nil, err @@ -464,6 +513,12 @@ func (p *AgentServiceTargetProvider) Package( serviceContext *azdext.ServiceContext, progress azdext.ProgressReporter, ) (*azdext.ServicePackageResult, error) { + // Prompt agents have no container/code to build — the harness owns the + // runtime. Skip packaging entirely. + if p.isPromptAgentService() { + return &azdext.ServicePackageResult{}, nil + } + // Code deploy: ZIP the source directory if p.isCodeDeployAgent() { progress("Packaging code") @@ -567,6 +622,11 @@ func (p *AgentServiceTargetProvider) Publish( publishOptions *azdext.PublishOptions, progress azdext.ProgressReporter, ) (*azdext.ServicePublishResult, error) { + // Prompt agents have no container image to publish. + if p.isPromptAgentService() { + return &azdext.ServicePublishResult{}, nil + } + // Code deploy skips Publish (no ACR needed) if p.isCodeDeployAgent() { return &azdext.ServicePublishResult{}, nil @@ -972,6 +1032,13 @@ func (p *AgentServiceTargetProvider) Deploy( targetResource *azdext.TargetResource, progress azdext.ProgressReporter, ) (*azdext.ServiceDeployResult, error) { + // Prompt agents are created on the managed harness, not the Foundry + // service. Dispatch to the dedicated harness deploy path before any + // ARM/Foundry resolution the hosted path requires. + if p.isPromptAgentService() { + return p.deployPromptAgent(ctx, serviceConfig, progress) + } + // Ensure Foundry project is loaded if err := p.ensureFoundryProject(ctx); err != nil { return nil, err diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go new file mode 100644 index 00000000000..e047f4b3312 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go @@ -0,0 +1,579 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "runtime/debug" + "slices" + "strings" + "time" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/braydonk/yaml" +) + +// serviceIsPromptAgent reports whether the service config describes a prompt +// (kind=managed) agent. Prompt agents carry a populated `promptAgent` block in +// their azure.yaml service config; hosted/workflow agents leave it nil. +func serviceIsPromptAgent(serviceConfig *azdext.ServiceConfig) bool { + if serviceConfig == nil || serviceConfig.Config == nil { + return false + } + var cfg ServiceTargetAgentConfig + if err := UnmarshalStruct(serviceConfig.Config, &cfg); err != nil { + return false + } + return cfg.PromptAgent != nil +} + +// isPromptAgentService reports whether the provider's current service is a +// prompt agent. +func (p *AgentServiceTargetProvider) isPromptAgentService() bool { + return serviceIsPromptAgent(p.serviceConfig) +} + +// promptAgentSettings extracts and validates the prompt-agent harness settings +// from the service config, applying environment-variable overrides. +func (p *AgentServiceTargetProvider) promptAgentSettings() (*PromptAgentSettings, error) { + var cfg ServiceTargetAgentConfig + if err := UnmarshalStruct(p.serviceConfig.Config, &cfg); err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("failed to parse service config: %s", err), + "check the service configuration in azure.yaml", + ) + } + if cfg.PromptAgent == nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "service config is missing the promptAgent block", + "re-run `azd ai agent init` to scaffold the prompt agent service", + ) + } + cfg.PromptAgent.ApplyEnvOverrides() + if err := cfg.PromptAgent.Validate(); err != nil { + return nil, err + } + return cfg.PromptAgent, nil +} + +// loadPromptAgentDefinition reads the agent.yaml as a bare ManagedAgent. +func (p *AgentServiceTargetProvider) loadPromptAgentDefinition() (agent_yaml.ManagedAgent, error) { + data, err := os.ReadFile(p.agentDefinitionPath) + if err != nil { + return agent_yaml.ManagedAgent{}, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("failed to read agent manifest file: %s", err), + "verify the agent.yaml file exists and is readable", + ) + } + var managed agent_yaml.ManagedAgent + if err := yaml.Unmarshal(data, &managed); err != nil { + return agent_yaml.ManagedAgent{}, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent.yaml is not a valid prompt agent: %s", err), + "fix the agent.yaml to match the prompt (managed) agent schema", + ) + } + if !strings.EqualFold(string(managed.Kind), string(agent_yaml.AgentKindManaged)) { + return agent_yaml.ManagedAgent{}, exterrors.Validation( + exterrors.CodeUnsupportedAgentKind, + fmt.Sprintf("agent.yaml declares kind %q, expected managed", managed.Kind), + "use kind: managed for prompt agents", + ) + } + return managed, nil +} + +// deployPromptAgent creates (or updates) the prompt agent on the managed +// harness and registers the resulting agent identity in the azd environment. +// It is the prompt-agent analogue of deployHostedAgent, dispatched from +// Deploy() when the service is a prompt agent. +func (p *AgentServiceTargetProvider) deployPromptAgent( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + progress azdext.ProgressReporter, +) (*azdext.ServiceDeployResult, error) { + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "panic in deployPromptAgent: %v\n%s\n", r, debug.Stack()) + panic(r) + } + }() + + managed, err := p.loadPromptAgentDefinition() + if err != nil { + return nil, err + } + + settings, err := p.promptAgentSettings() + if err != nil { + return nil, err + } + + // Overlay the provisioned Foundry project values from the azd environment + // onto any settings still at their default placeholder. This makes the + // "create a new Foundry project" init path work: `azd up` provisions the + // project, and the deploy targets it. The overlay is a no-op unless the azd + // environment actually holds a resolved project (AZURE_AI_PROJECT_NAME), + // so the local-dev fake tuple is preserved when no project was provisioned. + projectScopedTarget := false + if env, envErr := p.azdEnvValues(ctx); envErr == nil { + mappedFromProjectID, mapErr := ResolvePromptTargetFromEnv(settings, env) + if mapErr != nil { + return nil, mapErr + } + projectScopedTarget = mappedFromProjectID + if projectScopedTarget { + fmt.Fprintf( + os.Stderr, + "Resolved managed prompt target from AZURE_AI_PROJECT_ID: subscription=%q resourceGroup=%q workspace=%q.\n", + settings.SubscriptionID, + settings.ResourceGroup, + settings.Workspace, + ) + } + + // When the service already has an explicit non-placeholder workspace, + // trust it and avoid the RG-wide discovery path entirely. + workspaceKnown := strings.TrimSpace(settings.Workspace) != "" && + settings.Workspace != DefaultPromptWorkspace + + if !workspaceKnown && !projectScopedTarget { + if ws, ok := p.resolvePromptWorkspaceFromAzure(ctx, settings, env); ok { + if !strings.EqualFold(ws, settings.Workspace) { + fmt.Fprintf(os.Stderr, "Resolved prompt workspace to %q (was %q).\n", ws, settings.Workspace) + settings.Workspace = ws + } + } else { + // No AML workspace found — provision one. The managed harness API + // requires Microsoft.MachineLearningServices/workspaces/{name} to exist. + if progress != nil { + progress(fmt.Sprintf("Workspace %q not found; provisioning an AML workspace now", settings.Workspace)) + } + if createErr := ensurePromptWorkspaceExists(ctx, settings, env, progress); createErr != nil { + fmt.Fprintf(os.Stderr, "Warning: AML workspace provisioning failed: %v\n", createErr) + } + } + } else if workspaceKnown && !projectScopedTarget { + // No AML workspace found — provision one. The managed harness API + // Keep the explicit workspace from azure.yaml / env and skip discovery. + fmt.Fprintf(os.Stderr, "Using configured prompt workspace %q.\n", settings.Workspace) + } + } + + request, err := agent_yaml.CreateManagedAgentAPIRequest(managed, nil) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent.yaml is not a valid prompt agent: %s", err), + "ensure agent.yaml declares a non-empty model and instructions", + ) + } + + client, err := NewPromptAgentClient(settings) + if err != nil { + return nil, err + } + + if progress != nil { + progress("Creating prompt agent on the harness") + } + headers := map[string]string{ + "x-model-endpoint": settings.EffectiveModelEndpoint(), + } + agent, err := client.CreateAgentWithHeaders(ctx, request, settings.EffectiveAPIVersion(), headers) + if err != nil && isWorkspaceNotFoundError(err) && !projectScopedTarget { + // Workspace provisioning may not have finished or may have raced; retry once. + if env2, envErr2 := p.azdEnvValues(ctx); envErr2 == nil { + if createErr := ensurePromptWorkspaceExists(ctx, settings, env2, progress); createErr == nil { + fmt.Fprintf(os.Stderr, "Retrying agent creation after workspace provisioning.\n") + if client2, clientErr := NewPromptAgentClient(settings); clientErr == nil { + agent, err = client2.CreateAgentWithHeaders(ctx, request, settings.EffectiveAPIVersion(), headers) + } + } + } + } + if err != nil { + return nil, exterrors.ServiceFromAzure(err, exterrors.OpCreateAgent) + } + + latest := agent.Versions.Latest + if latest.Status != "active" { + polled, pollErr := p.waitForPromptAgentActive(ctx, client, request.Name, settings, progress) + if pollErr != nil { + return nil, pollErr + } + latest = *polled + } else { + fmt.Fprintf(os.Stderr, "Prompt agent %q version %s is already active.\n", request.Name, latest.Version) + } + + if err := p.registerPromptAgentEnvVars(ctx, serviceConfig, request.Name, latest.Version, settings); err != nil { + return nil, err + } + + if progress != nil { + progress("Prompt agent deployed") + } + return &azdext.ServiceDeployResult{}, nil +} + +// ProjectEndpointAPIVersion is the api-version used by the Foundry project +// data-plane managed agent endpoints +// (https://.services.ai.azure.com/api/projects//agents?api-version=v1). +const ProjectEndpointAPIVersion = "v1" + +// ResolvePromptTargetFromEnv applies azd environment-derived overrides to the +// prompt settings so both deploy and the lifecycle commands (show/invoke/list/ +// delete) target the same managed agent route. +// +// It resolves the Foundry project data-plane endpoint +// (https://.services.ai.azure.com/api/projects/), preferring +// the value already on the settings (set via interactive init) and otherwise +// falling back to AZURE_AI_PROJECT_ENDPOINT in the azd environment (covers +// --no-prompt and the provisioned-project path). When a project endpoint is +// available it becomes the authoritative routing target, the api-version is +// normalized to v1, and the model endpoint is derived from the account host. +// +// It returns true when a project-scoped target was resolved. +func ResolvePromptTargetFromEnv(settings *PromptAgentSettings, env map[string]string) (bool, error) { + if settings == nil || env == nil { + return false, nil + } + settings.OverlayAzdProjectEnv(env) + mapped, err := overlayPromptSettingsFromProjectResourceID(settings, env) + if err != nil { + return false, err + } + + // Prefer the config-supplied project endpoint (interactive init); otherwise + // read it from the azd environment (--no-prompt / provisioned project). + if strings.TrimSpace(settings.ProjectEndpoint) == "" { + if pe := strings.TrimSpace(env["AZURE_AI_PROJECT_ENDPOINT"]); pe != "" { + settings.ProjectEndpoint = pe + } + } + + if pe := strings.TrimSpace(settings.ProjectEndpoint); pe != "" { + // The project data-plane contract uses api-version=v1. + settings.APIVersion = ProjectEndpointAPIVersion + // x-model-endpoint targets the account host backing the project. + if u, perr := url.Parse(pe); perr == nil && u.Host != "" { + if strings.TrimSpace(settings.ModelEndpoint) == "" || + strings.EqualFold(strings.TrimSpace(settings.ModelEndpoint), DefaultPromptModelEndpoint) { + settings.ModelEndpoint = u.Scheme + "://" + u.Host + } + } + return true, nil + } + + return mapped, nil +} + +func overlayPromptSettingsFromProjectResourceID(settings *PromptAgentSettings, env map[string]string) (bool, error) { + if settings == nil || env == nil { + return false, nil + } + + projectResourceID := strings.TrimSpace(env["AZURE_AI_PROJECT_ID"]) + if projectResourceID == "" { + return false, nil + } + + parsedResource, err := arm.ParseResourceID(projectResourceID) + if err != nil { + return false, exterrors.Validation( + exterrors.CodeInvalidAiProjectId, + fmt.Sprintf("failed to parse AZURE_AI_PROJECT_ID: %s", err), + "verify AZURE_AI_PROJECT_ID points to a Foundry project ARM resource ID", + ) + } + + if parsedResource.Parent == nil || !strings.Contains(string(parsedResource.ResourceType.Type), "/") { + return false, exterrors.Validation( + exterrors.CodeInvalidAiProjectId, + fmt.Sprintf("AZURE_AI_PROJECT_ID is not a Foundry project resource ID: %q", projectResourceID), + "set AZURE_AI_PROJECT_ID to a Microsoft.CognitiveServices/accounts/projects resource ID", + ) + } + + settings.SubscriptionID = parsedResource.SubscriptionID + settings.ResourceGroup = parsedResource.ResourceGroupName + + if parsedResource.Parent != nil { + accountName := strings.TrimSpace(parsedResource.Parent.Name) + if accountName != "" { + // Managed CreateAgent routes are workspace-scoped. For Foundry projects, + // the backing AML workspace name follows: @@AML. + settings.Workspace = fmt.Sprintf("%s@%s@AML", accountName, parsedResource.Name) + sameAsDefault := strings.TrimSpace(settings.ModelEndpoint) == "" || + strings.EqualFold(strings.TrimSpace(settings.ModelEndpoint), DefaultPromptModelEndpoint) + if sameAsDefault { + settings.ModelEndpoint = fmt.Sprintf("https://%s.services.ai.azure.com", accountName) + } + } else { + settings.Workspace = parsedResource.Name + } + } else { + settings.Workspace = parsedResource.Name + } + + return true, nil +} + +// waitForPromptAgentActive polls the harness GetAgent endpoint until the +// agent's latest version reaches a terminal status. It returns the active +// version object, or a typed error on failure/timeout. +func (p *AgentServiceTargetProvider) waitForPromptAgentActive( + ctx context.Context, + client *agent_api.ManagedAgentClient, + agentName string, + settings *PromptAgentSettings, + progress azdext.ProgressReporter, +) (*agent_api.AgentVersionObject, error) { + const pollInterval = 5 * time.Second + const pollTimeout = 5 * time.Minute + + deadline := time.Now().Add(pollTimeout) + attempt := 0 + if progress != nil { + progress("Waiting for prompt agent to become active") + } + + var lastStatus string + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("deployment cancelled: %w", ctx.Err()) + case <-time.After(pollInterval): + } + + attempt++ + if progress != nil { + progress(fmt.Sprintf("Polling prompt agent status (attempt %d)", attempt)) + } + + agent, err := client.GetAgent(ctx, agentName, settings.EffectiveAPIVersion()) + if err != nil { + fmt.Fprintf(os.Stderr, " Warning: poll failed: %s\n", err) + continue + } + latest := agent.Versions.Latest + lastStatus = latest.Status + + switch latest.Status { + case "active": + fmt.Fprintf(os.Stderr, "Prompt agent version %s is active!\n", latest.Version) + return &latest, nil + case "failed": + errMsg := "prompt agent deployment failed" + if latest.Error != nil { + errMsg = fmt.Sprintf( + "prompt agent deployment failed: [%s] %s", latest.Error.Code, latest.Error.Message, + ) + } + return nil, exterrors.Internal(exterrors.CodeAgentCreateFailed, errMsg) + default: + fmt.Fprintf(os.Stderr, " Status: %s...\n", latest.Status) + } + } + + if lastStatus == "" { + lastStatus = "unknown" + } + return nil, exterrors.Internal( + exterrors.CodeAgentCreateFailed, + fmt.Sprintf("prompt agent deployment timed out (last status: %s); check status with 'azd ai agent show'", lastStatus), + ) +} + +// registerPromptAgentEnvVars stores the deployed prompt agent's identity and +// harness invocation endpoint in the azd environment, mirroring the hosted +// AGENT_{KEY}_* convention so downstream commands (show/invoke) resolve. +func (p *AgentServiceTargetProvider) registerPromptAgentEnvVars( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + agentName, version string, + settings *PromptAgentSettings, +) error { + if agentName == "" { + return fmt.Errorf("agent name is empty; cannot register environment variables") + } + + serviceKey := p.getServiceKey(serviceConfig.Name) + endpoint := promptAgentResponsesEndpoint(settings) + envVars := map[string]string{ + fmt.Sprintf("AGENT_%s_NAME", serviceKey): agentName, + fmt.Sprintf("AGENT_%s_VERSION", serviceKey): version, + fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey): endpoint, + } + + for key, value := range envVars { + if _, err := p.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: p.env.Name, + Key: key, + Value: value, + }); err != nil { + return fmt.Errorf("failed to set environment variable %s: %w", key, err) + } + } + return nil +} + +// promptAgentResponsesEndpoint builds the Responses URL the harness exposes for +// invoking a prompt agent. When a Foundry project data-plane endpoint is +// configured it is used directly; otherwise it falls back to the legacy +// workspace-rooted route. Best-effort: returns the base URL when neither can be +// built. +func promptAgentResponsesEndpoint(settings *PromptAgentSettings) string { + if pe := strings.TrimSpace(settings.ProjectEndpoint); pe != "" { + return strings.TrimRight(pe, "/") + "/openai/v1/responses" + } + prefix, err := agent_api.BuildWorkspaceRoutePrefix( + settings.SubscriptionID, settings.ResourceGroup, settings.Workspace, + ) + if err != nil { + return settings.BaseURL + } + return strings.TrimRight(settings.BaseURL, "/") + prefix + "/openai/responses?api-version=" + + settings.EffectiveAPIVersion() +} + +// azdEnvValues returns the current azd environment as a key/value map. Used to +// overlay provisioned Foundry project values onto the prompt settings at +// deploy time. +func (p *AgentServiceTargetProvider) azdEnvValues(ctx context.Context) (map[string]string, error) { + resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: p.env.Name, + }) + if err != nil { + return nil, err + } + values := make(map[string]string, len(resp.KeyValues)) + for _, kv := range resp.KeyValues { + values[kv.Key] = kv.Value + } + return values, nil +} + +// resolvePromptWorkspaceFromAzure discovers a valid AML workspace name for +// managed prompt routes from the target resource group. +// +// Selection order: +// 1. Keep the configured workspace when it already exists. +// 2. Prefer env-derived candidates that exist (project/account names). +// 3. Use the only workspace in the RG when exactly one exists. +func (p *AgentServiceTargetProvider) resolvePromptWorkspaceFromAzure( + ctx context.Context, + settings *PromptAgentSettings, + env map[string]string, +) (string, bool) { + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "Warning: workspace discovery panicked: %v\n", r) + } + }() + + if settings == nil { + return "", false + } + + // Prompt agents skip the hosted credential-init path so p.credential is nil. + // Fall back to the prompt harness credential so workspace discovery works. + var cred azcore.TokenCredential = p.credential + if cred == nil { + cred = promptCredential() + } + if cred == nil { + return "", false + } + + resourcesClient, err := armresources.NewClient(settings.SubscriptionID, cred, azure.NewArmClientOptions()) + if err != nil { + return "", false + } + + pager := resourcesClient.NewListByResourceGroupPager(settings.ResourceGroup, &armresources.ClientListByResourceGroupOptions{ + Filter: new("resourceType eq 'Microsoft.MachineLearningServices/workspaces'"), + }) + + workspaceNames := []string{} + for pager.More() { + page, pageErr := pager.NextPage(ctx) + if pageErr != nil { + return "", false + } + for _, resource := range page.Value { + if resource == nil || resource.Name == nil { + continue + } + name := strings.TrimSpace(*resource.Name) + if name == "" { + continue + } + workspaceNames = append(workspaceNames, name) + } + } + + if len(workspaceNames) == 0 { + return "", false + } + + containsFold := func(target string) bool { + return slices.ContainsFunc(workspaceNames, func(n string) bool { return strings.EqualFold(n, strings.TrimSpace(target)) }) + } + + if containsFold(settings.Workspace) { + return settings.Workspace, true + } + + candidates := []string{ + strings.TrimSpace(env["AZURE_AI_PROJECT_NAME"]), + strings.TrimSpace(env["AZURE_AI_ACCOUNT_NAME"]), + } + for _, candidate := range candidates { + if candidate == "" { + continue + } + if containsFold(candidate) { + return candidate, true + } + } + + if len(workspaceNames) == 1 { + return workspaceNames[0], true + } + + return "", false +} + +func isWorkspaceNotFoundError(err error) bool { + if err == nil { + return false + } + + if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + if strings.EqualFold(strings.TrimSpace(respErr.ErrorCode), "WorkspaceNotFound") { + return true + } + } + + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "workspacenotfound") || + strings.Contains(msg, "workspace not found") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/workspace_create.go b/cli/azd/extensions/azure.ai.agents/internal/project/workspace_create.go new file mode 100644 index 00000000000..c66e65041b3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/workspace_create.go @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/azure" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +const ( + amlWorkspaceAPIVersion = "2024-04-01" + storageAPIVersion = "2023-01-01" + keyVaultAPIVersion = "2023-07-01" +) + +// ensurePromptWorkspaceExists verifies that an AML workspace named +// settings.Workspace exists in the target resource group, and creates one— +// along with a storage account and key vault as prerequisites—when it is absent. +// +// The managed prompt-agent harness API routes every operation through: +// +// .../providers/Microsoft.MachineLearningServices/workspaces/{name}/... +// +// so the workspace must exist as an ARM resource before agents can be registered. +// +// Both AZURE_LOCATION and AZURE_TENANT_ID must be present in env. +// The function is idempotent: running it twice with the same settings produces the +// same storage/keyvault/workspace names and skips re-creation. +func ensurePromptWorkspaceExists( + ctx context.Context, + settings *PromptAgentSettings, + env map[string]string, + progress azdext.ProgressReporter, +) (retErr error) { + defer func() { + if r := recover(); r != nil { + retErr = fmt.Errorf("workspace provisioning panicked: %v", r) + } + }() + + if settings == nil { + return nil + } + + cred := promptCredential() + if cred == nil { + return fmt.Errorf("no credential available to provision the AML workspace") + } + + client, err := armresources.NewClient(settings.SubscriptionID, cred, azure.NewArmClientOptions()) + if err != nil { + return fmt.Errorf("creating ARM client: %w", err) + } + + wsResourceID := amlWorkspaceResourceID(settings.SubscriptionID, settings.ResourceGroup, settings.Workspace) + + // Fast-path: workspace already exists. + if _, err := client.GetByID(ctx, wsResourceID, amlWorkspaceAPIVersion, nil); err == nil { + return nil + } else if respErr, ok := errors.AsType[*azcore.ResponseError](err); !ok || respErr.StatusCode != 404 { + return fmt.Errorf("checking AML workspace existence: %w", err) + } + + location := strings.ToLower(strings.TrimSpace(env["AZURE_LOCATION"])) + if location == "" { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "AZURE_LOCATION is required to provision the AML workspace", + "run 'azd env set AZURE_LOCATION ' and re-deploy", + ) + } + + tenantID := strings.TrimSpace(env["AZURE_TENANT_ID"]) + if tenantID == "" { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "AZURE_TENANT_ID is required to provision the AML workspace's key vault", + "run 'azd env set AZURE_TENANT_ID ' and re-deploy", + ) + } + + // Suffix is deterministic so repeated deploys reuse the same dependencies. + suffix := amlDependencyNameSuffix(settings.SubscriptionID, settings.ResourceGroup, settings.Workspace) + + if progress != nil { + progress(fmt.Sprintf("Provisioning storage account for workspace %q", settings.Workspace)) + } + storageID, err := ensureStorageAccountForWorkspace(ctx, client, settings, location, suffix) + if err != nil { + return fmt.Errorf("provisioning storage account: %w", err) + } + + if progress != nil { + progress(fmt.Sprintf("Provisioning key vault for workspace %q", settings.Workspace)) + } + kvID, err := ensureKeyVaultForWorkspace(ctx, client, settings, location, suffix, tenantID) + if err != nil { + return fmt.Errorf("provisioning key vault: %w", err) + } + + if progress != nil { + progress(fmt.Sprintf("Creating AML workspace %q", settings.Workspace)) + } + if err := createAMLWorkspace(ctx, client, wsResourceID, location, storageID, kvID); err != nil { + return fmt.Errorf("creating AML workspace: %w", err) + } + if progress != nil { + progress(fmt.Sprintf("AML workspace %q is ready", settings.Workspace)) + } + return nil +} + +func amlWorkspaceResourceID(subscriptionID, resourceGroup, name string) string { + return fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.MachineLearningServices/workspaces/%s", + subscriptionID, resourceGroup, name, + ) +} + +// amlDependencyNameSuffix returns 8 lower-hex characters derived deterministically +// from the given strings. Storage account and key vault names are built from this +// suffix so repeated deploys reuse the same backing resources. +func amlDependencyNameSuffix(parts ...string) string { + h := sha256.New() + for _, p := range parts { + _, _ = fmt.Fprintf(h, "%s\x00", p) + } + return hex.EncodeToString(h.Sum(nil))[:8] +} + +// ensureStorageAccountForWorkspace idempotently creates (or reuses) the storage +// account that AML workspace creation requires. +func ensureStorageAccountForWorkspace( + ctx context.Context, + client *armresources.Client, + settings *PromptAgentSettings, + location, suffix string, +) (string, error) { + // Storage account names: max 24 chars, lowercase alphanumeric only. + name := "st" + suffix // "st" + 8 hex chars = 10 chars + resourceID := fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Storage/storageAccounts/%s", + settings.SubscriptionID, settings.ResourceGroup, name, + ) + if _, err := client.GetByID(ctx, resourceID, storageAPIVersion, nil); err == nil { + return resourceID, nil // already exists + } + skuName := "Standard_LRS" + kind := "StorageV2" + body := armresources.GenericResource{ + Location: &location, + Kind: &kind, + SKU: &armresources.SKU{Name: &skuName}, + Properties: map[string]interface{}{ + "supportsHttpsTrafficOnly": true, + "accessTier": "Hot", + }, + } + poller, err := client.BeginCreateOrUpdateByID(ctx, resourceID, storageAPIVersion, body, nil) + if err != nil { + return "", err + } + if _, err = poller.PollUntilDone(ctx, nil); err != nil { + return "", err + } + return resourceID, nil +} + +// ensureKeyVaultForWorkspace idempotently creates (or reuses) the key vault that +// AML workspace creation requires. +func ensureKeyVaultForWorkspace( + ctx context.Context, + client *armresources.Client, + settings *PromptAgentSettings, + location, suffix, tenantID string, +) (string, error) { + // Key vault names: 3–24 chars, alphanumeric + hyphens. + name := "kv-" + suffix // "kv-" + 8 hex chars = 11 chars + resourceID := fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.KeyVault/vaults/%s", + settings.SubscriptionID, settings.ResourceGroup, name, + ) + if _, err := client.GetByID(ctx, resourceID, keyVaultAPIVersion, nil); err == nil { + return resourceID, nil // already exists + } + body := armresources.GenericResource{ + Location: &location, + Properties: map[string]interface{}{ + "sku": map[string]interface{}{"family": "A", "name": "standard"}, + "tenantId": tenantID, + "accessPolicies": []interface{}{}, + "enableSoftDelete": true, + }, + } + poller, err := client.BeginCreateOrUpdateByID(ctx, resourceID, keyVaultAPIVersion, body, nil) + if err != nil { + return "", err + } + if _, err = poller.PollUntilDone(ctx, nil); err != nil { + return "", err + } + return resourceID, nil +} + +// createAMLWorkspace creates the Microsoft.MachineLearningServices/workspaces +// resource. It is designed to be called AFTER the prerequisite storage account +// and key vault have been created. +func createAMLWorkspace( + ctx context.Context, + client *armresources.Client, + workspaceResourceID, location, storageID, kvID string, +) error { + identityType := armresources.ResourceIdentityTypeSystemAssigned + body := armresources.GenericResource{ + Location: &location, + Identity: &armresources.Identity{Type: &identityType}, + Properties: map[string]interface{}{ + "storageAccount": storageID, + "keyVault": kvID, + }, + } + poller, err := client.BeginCreateOrUpdateByID(ctx, workspaceResourceID, amlWorkspaceAPIVersion, body, nil) + if err != nil { + return err + } + _, err = poller.PollUntilDone(ctx, nil) + return err +} diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/.gitignore b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/.gitignore new file mode 100644 index 00000000000..8e84380248d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/.gitignore @@ -0,0 +1 @@ +.azure diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/azure.yaml b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/azure.yaml new file mode 100644 index 00000000000..b72682192eb --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/azure.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json +name: ai-foundry-starter-basic + +infra: + provider: bicep + path: ./infra + +requiredVersions: + extensions: + # the azd ai agent extension is required for this template + "azure.ai.agents": ">=0.1.0-preview" + diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/abbreviations.json b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/abbreviations.json new file mode 100644 index 00000000000..879b2a9507b --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/abbreviations.json @@ -0,0 +1,137 @@ +{ + "aiFoundryAccounts": "aif", + "analysisServicesServers": "as", + "apiManagementService": "apim-", + "appConfigurationStores": "appcs-", + "appManagedEnvironments": "cae-", + "appContainerApps": "ca-", + "authorizationPolicyDefinitions": "policy-", + "automationAutomationAccounts": "aa-", + "blueprintBlueprints": "bp-", + "blueprintBlueprintsArtifacts": "bpa-", + "cacheRedis": "redis-", + "cdnProfiles": "cdnp-", + "cdnProfilesEndpoints": "cdne-", + "cognitiveServicesAccounts": "cog-", + "cognitiveServicesFormRecognizer": "cog-fr-", + "cognitiveServicesTextAnalytics": "cog-ta-", + "computeAvailabilitySets": "avail-", + "computeCloudServices": "cld-", + "computeDiskEncryptionSets": "des", + "computeDisks": "disk", + "computeDisksOs": "osdisk", + "computeGalleries": "gal", + "computeSnapshots": "snap-", + "computeVirtualMachines": "vm", + "computeVirtualMachineScaleSets": "vmss-", + "containerInstanceContainerGroups": "ci", + "containerRegistryRegistries": "cr", + "containerServiceManagedClusters": "aks-", + "databricksWorkspaces": "dbw-", + "dataFactoryFactories": "adf-", + "dataLakeAnalyticsAccounts": "dla", + "dataLakeStoreAccounts": "dls", + "dataMigrationServices": "dms-", + "dBforMySQLServers": "mysql-", + "dBforPostgreSQLServers": "psql-", + "devicesIotHubs": "iot-", + "devicesProvisioningServices": "provs-", + "devicesProvisioningServicesCertificates": "pcert-", + "documentDBDatabaseAccounts": "cosmos-", + "documentDBMongoDatabaseAccounts": "cosmon-", + "eventGridDomains": "evgd-", + "eventGridDomainsTopics": "evgt-", + "eventGridEventSubscriptions": "evgs-", + "eventHubNamespaces": "evhns-", + "eventHubNamespacesEventHubs": "evh-", + "hdInsightClustersHadoop": "hadoop-", + "hdInsightClustersHbase": "hbase-", + "hdInsightClustersKafka": "kafka-", + "hdInsightClustersMl": "mls-", + "hdInsightClustersSpark": "spark-", + "hdInsightClustersStorm": "storm-", + "hybridComputeMachines": "arcs-", + "insightsActionGroups": "ag-", + "insightsComponents": "appi-", + "keyVaultVaults": "kv-", + "kubernetesConnectedClusters": "arck", + "kustoClusters": "dec", + "kustoClustersDatabases": "dedb", + "logicIntegrationAccounts": "ia-", + "logicWorkflows": "logic-", + "machineLearningServicesWorkspaces": "mlw-", + "managedIdentityUserAssignedIdentities": "id-", + "managementManagementGroups": "mg-", + "migrateAssessmentProjects": "migr-", + "networkApplicationGateways": "agw-", + "networkApplicationSecurityGroups": "asg-", + "networkAzureFirewalls": "afw-", + "networkBastionHosts": "bas-", + "networkConnections": "con-", + "networkDnsZones": "dnsz-", + "networkExpressRouteCircuits": "erc-", + "networkFirewallPolicies": "afwp-", + "networkFirewallPoliciesWebApplication": "waf", + "networkFirewallPoliciesRuleGroups": "wafrg", + "networkFrontDoors": "fd-", + "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", + "networkLoadBalancersExternal": "lbe-", + "networkLoadBalancersInternal": "lbi-", + "networkLoadBalancersInboundNatRules": "rule-", + "networkLocalNetworkGateways": "lgw-", + "networkNatGateways": "ng-", + "networkNetworkInterfaces": "nic-", + "networkNetworkSecurityGroups": "nsg-", + "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", + "networkNetworkWatchers": "nw-", + "networkPrivateDnsZones": "pdnsz-", + "networkPrivateLinkServices": "pl-", + "networkPublicIPAddresses": "pip-", + "networkPublicIPPrefixes": "ippre-", + "networkRouteFilters": "rf-", + "networkRouteTables": "rt-", + "networkRouteTablesRoutes": "udr-", + "networkTrafficManagerProfiles": "traf-", + "networkVirtualNetworkGateways": "vgw-", + "networkVirtualNetworks": "vnet-", + "networkVirtualNetworksSubnets": "snet-", + "networkVirtualNetworksVirtualNetworkPeerings": "peer-", + "networkVirtualWans": "vwan-", + "networkVpnGateways": "vpng-", + "networkVpnGatewaysVpnConnections": "vcn-", + "networkVpnGatewaysVpnSites": "vst-", + "notificationHubsNamespaces": "ntfns-", + "notificationHubsNamespacesNotificationHubs": "ntf-", + "operationalInsightsWorkspaces": "log-", + "portalDashboards": "dash-", + "powerBIDedicatedCapacities": "pbi-", + "purviewAccounts": "pview-", + "recoveryServicesVaults": "rsv-", + "resourcesResourceGroups": "rg-", + "searchSearchServices": "srch-", + "serviceBusNamespaces": "sb-", + "serviceBusNamespacesQueues": "sbq-", + "serviceBusNamespacesTopics": "sbt-", + "serviceEndPointPolicies": "se-", + "serviceFabricClusters": "sf-", + "signalRServiceSignalR": "sigr", + "sqlManagedInstances": "sqlmi-", + "sqlServers": "sql-", + "sqlServersDataWarehouse": "sqldw-", + "sqlServersDatabases": "sqldb-", + "sqlServersDatabasesStretch": "sqlstrdb-", + "storageStorageAccounts": "st", + "storageStorageAccountsVm": "stvm", + "storSimpleManagers": "ssimp", + "streamAnalyticsCluster": "asa-", + "synapseWorkspaces": "syn", + "synapseWorkspacesAnalyticsWorkspaces": "synw", + "synapseWorkspacesSqlPoolsDedicated": "syndp", + "synapseWorkspacesSqlPoolsSpark": "synsp", + "timeSeriesInsightsEnvironments": "tsi-", + "webServerFarms": "plan-", + "webSitesAppService": "app-", + "webSitesAppServiceEnvironment": "ase-", + "webSitesFunctions": "func-", + "webStaticSites": "stapp-" +} diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/acr-role-assignment.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/acr-role-assignment.bicep new file mode 100644 index 00000000000..3e0c2b218be --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/acr-role-assignment.bicep @@ -0,0 +1,27 @@ +targetScope = 'resourceGroup' + +@description('Name of the existing container registry') +param acrName string + +@description('Principal ID to grant AcrPull role') +param principalId string + +@description('Full resource ID of the ACR (for generating unique GUID)') +param acrResourceId string + +// Reference the existing ACR in this resource group +resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: acrName +} + +// Grant AcrPull role to the AI project's managed identity +resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: acr + name: guid(acrResourceId, principalId, '7f951dda-4ed3-4680-a7ca-43fe172d538d') + properties: { + principalId: principalId + principalType: 'ServicePrincipal' + // AcrPull role + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } +} diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/ai-project.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/ai-project.bicep new file mode 100644 index 00000000000..31b06ad76a2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/ai-project.bicep @@ -0,0 +1,417 @@ +targetScope = 'resourceGroup' + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Main location for the resources') +param location string + +@description('Optional salt to diversify resource names across project recreations') +param resourceTokenSalt string = '' + +var resourceToken = empty(resourceTokenSalt) ? uniqueString(subscription().id, resourceGroup().id, location) : uniqueString(subscription().id, resourceGroup().id, location, resourceTokenSalt) + +@description('Name of the project') +param aiFoundryProjectName string + +param deployments deploymentsType + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('Optional. Name of an existing AI Services account in the current resource group. If not provided, a new one will be created.') +param existingAiAccountName string = '' + +@description('List of connections to provision') +param connections array = [] + +@secure() +@description('Map of connection name to credentials object. Kept as @secure to prevent secrets from appearing in deployment logs. Example: { "my-conn": { "key": "secret" } }') +param connectionCredentials object = {} + +@description('Also provision dependent resources and connect to the project') +param additionalDependentResources dependentResourcesType + +@description('Enable monitoring via appinsights and log analytics') +param enableMonitoring bool = true + +@description('Enable hosted agent deployment') +param enableHostedAgents bool = false + +@description('Enable the capability host for agent conversations. When false and hosted agents are enabled, the capability host is not created (v2 hosted agents handle storage automatically).') +param enableCapabilityHost bool = true + +@description('Optional. Existing container registry resource ID. If provided, a connection will be created to this ACR instead of creating a new one.') +param existingContainerRegistryResourceId string = '' + +@description('Optional. Existing container registry login server (e.g., myregistry.azurecr.io). Required if existingContainerRegistryResourceId is provided.') +param existingContainerRegistryEndpoint string = '' + +@description('Optional. Name of an existing ACR connection on the Foundry project. If provided, no new ACR or connection will be created.') +param existingAcrConnectionName string = '' + +@description('Optional. Existing Application Insights connection string. If provided, a connection will be created but no new App Insights resource.') +param existingApplicationInsightsConnectionString string = '' + +@description('Optional. Existing Application Insights resource ID. Used for connection metadata when providing an existing App Insights.') +param existingApplicationInsightsResourceId string = '' + +@description('Optional. Name of an existing Application Insights connection on the Foundry project. If provided, no new App Insights or connection will be created.') +param existingAppInsightsConnectionName string = '' + +// Load abbreviations +var abbrs = loadJsonContent('../../abbreviations.json') + +// Determine which resources to create based on connections +var hasStorageConnection = length(filter(additionalDependentResources, conn => conn.resource == 'storage')) > 0 +var hasAcrConnection = length(filter(additionalDependentResources, conn => conn.resource == 'registry')) > 0 +var hasExistingAcr = !empty(existingContainerRegistryResourceId) +var hasExistingAcrConnection = !empty(existingAcrConnectionName) +var hasExistingAppInsightsConnection = !empty(existingAppInsightsConnectionName) +var hasExistingAppInsightsConnectionString = !empty(existingApplicationInsightsConnectionString) +// Only create new App Insights resources if monitoring enabled and no existing connection/connection string +var shouldCreateAppInsights = enableMonitoring && !hasExistingAppInsightsConnection && !hasExistingAppInsightsConnectionString +var hasSearchConnection = length(filter(additionalDependentResources, conn => conn.resource == 'azure_ai_search')) > 0 +var hasBingConnection = length(filter(additionalDependentResources, conn => conn.resource == 'bing_grounding')) > 0 +var hasBingCustomConnection = length(filter(additionalDependentResources, conn => conn.resource == 'bing_custom_grounding')) > 0 + +// Extract connection names from ai.yaml for each resource type +var storageConnectionName = hasStorageConnection ? filter(additionalDependentResources, conn => conn.resource == 'storage')[0].connectionName : '' +var acrConnectionName = hasAcrConnection ? filter(additionalDependentResources, conn => conn.resource == 'registry')[0].connectionName : '' +var searchConnectionName = hasSearchConnection ? filter(additionalDependentResources, conn => conn.resource == 'azure_ai_search')[0].connectionName : '' +var bingConnectionName = hasBingConnection ? filter(additionalDependentResources, conn => conn.resource == 'bing_grounding')[0].connectionName : '' +var bingCustomConnectionName = hasBingCustomConnection ? filter(additionalDependentResources, conn => conn.resource == 'bing_custom_grounding')[0].connectionName : '' + +// Enable monitoring via Log Analytics and Application Insights +module logAnalytics '../monitor/loganalytics.bicep' = if (shouldCreateAppInsights) { + name: 'logAnalytics' + params: { + location: location + tags: tags + name: 'logs-${resourceToken}' + } +} + +module applicationInsights '../monitor/applicationinsights.bicep' = if (shouldCreateAppInsights) { + name: 'applicationInsights' + params: { + location: location + tags: tags + name: 'appi-${resourceToken}' + logAnalyticsWorkspaceId: logAnalytics.outputs.id + projectMIPrincipalId: aiAccount::project.identity.principalId + } +} + +// Always create a new AI Account for now (simplified approach) +// TODO: Add support for existing accounts in a future version +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { + name: !empty(existingAiAccountName) ? existingAiAccountName : 'ai-account-${resourceToken}' + location: location + tags: tags + sku: { + name: 'S0' + } + kind: 'AIServices' + identity: { + type: 'SystemAssigned' + } + properties: { + allowProjectManagement: true + customSubDomainName: !empty(existingAiAccountName) ? existingAiAccountName : 'ai-account-${resourceToken}' + networkAcls: { + defaultAction: 'Allow' + virtualNetworkRules: [] + ipRules: [] + } + publicNetworkAccess: 'Enabled' + disableLocalAuth: true + } + + @batchSize(1) + resource seqDeployments 'deployments' = [ + for dep in (deployments??[]): { + name: dep.name + properties: { + model: dep.model + } + sku: dep.sku + } + ] + + resource project 'projects' = { + name: aiFoundryProjectName + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + description: '${aiFoundryProjectName} Project' + displayName: '${aiFoundryProjectName}Project' + } + dependsOn: [ + seqDeployments + ] + } + + resource aiFoundryAccountCapabilityHost 'capabilityHosts@2025-10-01-preview' = if (enableHostedAgents && enableCapabilityHost) { + name: 'agents' + properties: { + capabilityHostKind: 'Agents' + // IMPORTANT: this is required to enable hosted agents deployment + // if no BYO Net is provided + enablePublicHostingEnvironment: true + } + } +} + + +// Create connection towards appinsights: +// - when we create a new App Insights resource, OR +// - when the user provided an existing App Insights connection string + resource ID but no existing connection name +// Both cases are merged into a single resource to avoid duplicate ARM resource definitions (which fail deployment). +var shouldCreateExistingAppInsightsConnection = enableMonitoring && hasExistingAppInsightsConnectionString && !hasExistingAppInsightsConnection && !empty(existingApplicationInsightsResourceId) +var shouldCreateAppInsightsConnection = shouldCreateAppInsights || shouldCreateExistingAppInsightsConnection + +resource appInsightConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (shouldCreateAppInsightsConnection) { + parent: aiAccount::project + name: 'appi-${resourceToken}' + properties: { + category: 'AppInsights' + target: shouldCreateAppInsights ? applicationInsights.outputs.id : existingApplicationInsightsResourceId + authType: 'ApiKey' + isSharedToAll: true + credentials: { + key: shouldCreateAppInsights ? applicationInsights.outputs.connectionString : existingApplicationInsightsConnectionString + } + metadata: { + ApiType: 'Azure' + ResourceId: shouldCreateAppInsights ? applicationInsights.outputs.id : existingApplicationInsightsResourceId + } + } +} + +// Create additional connections from ai.yaml configuration +module aiConnections './connection.bicep' = [for (connection, index) in connections: { + name: 'connection-${connection.name}' + params: { + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + connectionConfig: connection + credentials: connectionCredentials[?connection.name] ?? {} + } +}] + +// Azure AI User for the developer, scoped to the Foundry Project. +// Project scope is sufficient for creating/running agents and calling models via the project endpoint. +resource localUserAzureAIUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: aiAccount::project + name: guid(subscription().id, resourceGroup().id, principalId, '53ca6127-db72-4b80-b1b0-d745d6d5456d') + properties: { + principalId: principalId + principalType: principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '53ca6127-db72-4b80-b1b0-d745d6d5456d') + } +} + + +// All connections are now created directly within their respective resource modules +// using the centralized ./connection.bicep module + +// Storage module - deploy if storage connection is defined in ai.yaml +module storage '../storage/storage.bicep' = if (hasStorageConnection) { + name: 'storage' + params: { + location: location + tags: tags + resourceName: 'st${resourceToken}' + connectionName: storageConnectionName + principalId: principalId + principalType: principalType + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + } +} + +// Azure Container Registry module - deploy if ACR connection is defined in ai.yaml +module acr '../host/acr.bicep' = if (hasAcrConnection) { + name: 'acr' + params: { + location: location + tags: tags + resourceName: '${abbrs.containerRegistryRegistries}${resourceToken}' + connectionName: acrConnectionName + principalId: principalId + principalType: principalType + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + } +} + +// Connection for existing ACR - create if user provided an existing ACR resource ID but no existing connection +module existingAcrConnection './connection.bicep' = if (hasExistingAcr && !hasExistingAcrConnection) { + name: 'existing-acr-connection' + params: { + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + connectionConfig: { + name: 'acr-${resourceToken}' + category: 'ContainerRegistry' + target: existingContainerRegistryEndpoint + authType: 'ManagedIdentity' + isSharedToAll: true + metadata: { + ResourceId: existingContainerRegistryResourceId + } + } + credentials: { + clientId: aiAccount::project.identity.principalId + resourceId: existingContainerRegistryResourceId + } + } +} + +// Extract resource group name from the existing ACR resource ID +// Resource ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ContainerRegistry/registries/{name} +var existingAcrResourceGroup = hasExistingAcr ? split(existingContainerRegistryResourceId, '/')[4] : '' +var existingAcrName = hasExistingAcr ? last(split(existingContainerRegistryResourceId, '/')) : '' + +// Grant AcrPull role to the AI project's managed identity on the existing ACR +// This allows the hosted agents to pull images from the user-provided registry +// Note: User must have permission to assign roles on the existing ACR (Owner or User Access Administrator) +// Using a module allows scoping to a different resource group if the ACR isn't in the same RG +// Skip if connection already exists (role assignment should already be in place) +module existingAcrRoleAssignment './acr-role-assignment.bicep' = if (hasExistingAcr && !hasExistingAcrConnection) { + name: 'existing-acr-role-assignment' + scope: resourceGroup(existingAcrResourceGroup) + params: { + acrName: existingAcrName + acrResourceId: existingContainerRegistryResourceId + principalId: aiAccount::project.identity.principalId + } +} + +// Bing Search grounding module - deploy if Bing connection is defined in ai.yaml or parameter is enabled +module bingGrounding '../search/bing_grounding.bicep' = if (hasBingConnection) { + name: 'bing-grounding' + params: { + tags: tags + resourceName: 'bing-${resourceToken}' + connectionName: bingConnectionName + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + } +} + +// Bing Custom Search grounding module - deploy if custom Bing connection is defined in ai.yaml or parameter is enabled +module bingCustomGrounding '../search/bing_custom_grounding.bicep' = if (hasBingCustomConnection) { + name: 'bing-custom-grounding' + params: { + tags: tags + resourceName: 'bingcustom-${resourceToken}' + connectionName: bingCustomConnectionName + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + } +} + +// Azure AI Search module - deploy if search connection is defined in ai.yaml +module azureAiSearch '../search/azure_ai_search.bicep' = if (hasSearchConnection) { + name: 'azure-ai-search' + params: { + tags: tags + resourceName: 'search-${resourceToken}' + connectionName: searchConnectionName + storageAccountResourceId: hasStorageConnection ? storage!.outputs.storageAccountId : '' + containerName: 'knowledge' + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + principalId: principalId + principalType: principalType + location: location + } +} + +// Outputs +output AZURE_AI_PROJECT_ENDPOINT string = aiAccount::project.properties.endpoints['AI Foundry API'] +output FOUNDRY_PROJECT_ENDPOINT string = aiAccount::project.properties.endpoints['AI Foundry API'] +output AZURE_OPENAI_ENDPOINT string = aiAccount.properties.endpoints['OpenAI Language Model Instance API'] +output aiServicesEndpoint string = aiAccount.properties.endpoint +output accountId string = aiAccount.id +output projectId string = aiAccount::project.id +output aiServicesAccountName string = aiAccount.name +output aiServicesProjectName string = aiAccount::project.name +output aiServicesPrincipalId string = aiAccount.identity.principalId +output projectName string = aiAccount::project.name +output APPLICATIONINSIGHTS_CONNECTION_STRING string = shouldCreateAppInsights ? applicationInsights.outputs.connectionString : (hasExistingAppInsightsConnectionString ? existingApplicationInsightsConnectionString : '') +output APPLICATIONINSIGHTS_RESOURCE_ID string = shouldCreateAppInsights ? applicationInsights.outputs.id : (hasExistingAppInsightsConnectionString ? existingApplicationInsightsResourceId : '') + +// Connection outputs from the connections array +output connectionIds array = [for (connection, index) in (connections ?? []): { + name: aiConnections[index].outputs.connectionName + id: aiConnections[index].outputs.connectionId +}] + +// Grouped dependent resources outputs +output dependentResources object = { + registry: { + name: hasAcrConnection ? acr!.outputs.containerRegistryName : '' + loginServer: hasAcrConnection ? acr!.outputs.containerRegistryLoginServer : ((hasExistingAcr || hasExistingAcrConnection) ? existingContainerRegistryEndpoint : '') + connectionName: hasAcrConnection ? acr!.outputs.containerRegistryConnectionName : (hasExistingAcrConnection ? existingAcrConnectionName : (hasExistingAcr ? 'acr-${resourceToken}' : '')) + } + bing_grounding: { + name: (hasBingConnection) ? bingGrounding!.outputs.bingGroundingName : '' + connectionName: (hasBingConnection) ? bingGrounding!.outputs.bingGroundingConnectionName : '' + connectionId: (hasBingConnection) ? bingGrounding!.outputs.bingGroundingConnectionId : '' + } + bing_custom_grounding: { + name: (hasBingCustomConnection) ? bingCustomGrounding!.outputs.bingCustomGroundingName : '' + connectionName: (hasBingCustomConnection) ? bingCustomGrounding!.outputs.bingCustomGroundingConnectionName : '' + connectionId: (hasBingCustomConnection) ? bingCustomGrounding!.outputs.bingCustomGroundingConnectionId : '' + } + search: { + serviceName: hasSearchConnection ? azureAiSearch!.outputs.searchServiceName : '' + connectionName: hasSearchConnection ? azureAiSearch!.outputs.searchConnectionName : '' + } + storage: { + accountName: hasStorageConnection ? storage!.outputs.storageAccountName : '' + connectionName: hasStorageConnection ? storage!.outputs.storageConnectionName : '' + } +} + +type deploymentsType = { + @description('Specify the name of cognitive service account deployment.') + name: string + + @description('Required. Properties of Cognitive Services account deployment model.') + model: { + @description('Required. The name of Cognitive Services account deployment model.') + name: string + + @description('Required. The format of Cognitive Services account deployment model.') + format: string + + @description('Required. The version of Cognitive Services account deployment model.') + version: string + } + + @description('The resource model definition representing SKU.') + sku: { + @description('Required. The name of the resource model definition representing SKU.') + name: string + + @description('The capacity of the resource model definition representing SKU.') + capacity: int + } +}[]? + +type dependentResourcesType = { + @description('The type of dependent resource to create') + resource: 'storage' | 'registry' | 'azure_ai_search' | 'bing_grounding' | 'bing_custom_grounding' + + @description('The connection name for this resource') + connectionName: string +}[] diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/connection.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/connection.bicep new file mode 100644 index 00000000000..a0872664524 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/connection.bicep @@ -0,0 +1,112 @@ +targetScope = 'resourceGroup' + +@description('AI Services account name') +param aiServicesAccountName string + +@description('AI project name') +param aiProjectName string + +// Connection configuration type definition +type ConnectionConfig = { + @description('Name of the connection') + name: string + + @description('Category of the connection (e.g., ContainerRegistry, AzureStorageAccount, CognitiveSearch, AzureOpenAI)') + category: string + + @description('Target endpoint or URL for the connection') + target: string + + @description('Authentication type') + authType: 'AAD' | 'AccessKey' | 'AccountKey' | 'AgenticIdentity' | 'ApiKey' | 'CustomKeys' | 'ManagedIdentity' | 'None' | 'OAuth2' | 'PAT' | 'SAS' | 'ServicePrincipal' | 'UsernamePassword' | 'UserEntraToken' | 'ProjectManagedIdentity' + + @description('Whether the connection is shared to all users (optional, defaults to true)') + isSharedToAll: bool? + + @description('Additional metadata for the connection (optional)') + metadata: object? + + @description('Error message if the connection fails (optional)') + error: string? + + @description('Expiry time for the connection (optional)') + expiryTime: string? + + @description('Private endpoint requirement: Required, NotRequired, or NotApplicable (optional)') + peRequirement: ('NotApplicable' | 'NotRequired' | 'Required')? + + @description('Private endpoint status: Active, Inactive, or NotApplicable (optional)') + peStatus: ('Active' | 'Inactive' | 'NotApplicable')? + + @description('List of users to share the connection with (optional, alternative to isSharedToAll)') + sharedUserList: string[]? + + @description('Whether to use workspace managed identity (optional)') + useWorkspaceManagedIdentity: bool? + + @description('OAuth2 authorization endpoint URL (optional, OAuth2 authType only)') + authorizationUrl: string? + + @description('OAuth2 token endpoint URL (optional, OAuth2 authType only)') + tokenUrl: string? + + @description('OAuth2 refresh token endpoint URL (optional, OAuth2 authType only)') + refreshUrl: string? + + @description('OAuth2 scopes to request (optional, OAuth2 authType only)') + scopes: string[]? + + @description('Token audience for UserEntraToken / AgenticIdentity auth types (optional)') + audience: string? + + @description('Managed connector name for OAuth2 managed connectors (optional)') + connectorName: string? +} + +@description('Connection configuration') +param connectionConfig ConnectionConfig + +@secure() +@description('Credentials for the connection. Kept as a separate @secure parameter to prevent secrets from appearing in deployment logs. Shape depends on authType — e.g. { key: "..." } for ApiKey, { clientId: "...", clientSecret: "..." } for OAuth2/ServicePrincipal.') +param credentials object = {} + + +// Get reference to the AI Services account and project +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: aiServicesAccountName + + resource project 'projects' existing = { + name: aiProjectName + } +} + +// Create the connection +resource connection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = { + parent: aiAccount::project + name: connectionConfig.name + properties: { + category: connectionConfig.category + target: connectionConfig.target + authType: connectionConfig.authType + isSharedToAll: connectionConfig.?isSharedToAll ?? true + credentials: !empty(credentials) ? credentials : null + metadata: connectionConfig.?metadata + // Only include if they appear in the connectionConfig + ...connectionConfig.?error != null ? { error: connectionConfig.?error } : {} + ...connectionConfig.?expiryTime != null ? { expiryTime: connectionConfig.?expiryTime } : {} + ...connectionConfig.?peRequirement != null ? { peRequirement: connectionConfig.?peRequirement } : {} + ...connectionConfig.?peStatus != null ? { peStatus: connectionConfig.?peStatus } : {} + ...connectionConfig.?sharedUserList != null ? { sharedUserList: connectionConfig.?sharedUserList } : {} + ...connectionConfig.?useWorkspaceManagedIdentity != null ? { useWorkspaceManagedIdentity: connectionConfig.?useWorkspaceManagedIdentity } : {} + ...connectionConfig.?authorizationUrl != null ? { authorizationUrl: connectionConfig.?authorizationUrl } : {} + ...connectionConfig.?tokenUrl != null ? { tokenUrl: connectionConfig.?tokenUrl } : {} + ...connectionConfig.?refreshUrl != null ? { refreshUrl: connectionConfig.?refreshUrl } : {} + ...connectionConfig.?scopes != null ? { scopes: connectionConfig.?scopes } : {} + ...connectionConfig.?audience != null ? { audience: connectionConfig.?audience } : {} + ...connectionConfig.?connectorName != null ? { connectorName: connectionConfig.?connectorName } : {} + } +} + +// Outputs +output connectionName string = connection.name +output connectionId string = connection.id diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/existing-ai-project.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/existing-ai-project.bicep new file mode 100644 index 00000000000..12e5a1217b2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/existing-ai-project.bicep @@ -0,0 +1,140 @@ +targetScope = 'resourceGroup' + +@description('Name of the existing AI Services account') +param aiServicesAccountName string + +@description('Name of the existing AI Foundry project') +param aiFoundryProjectName string + +@description('Existing ACR connection name (already set in the environment)') +param existingAcrConnectionName string = '' + +@description('Existing container registry endpoint (already set in the environment)') +param existingContainerRegistryEndpoint string = '' + +@description('Existing Application Insights connection string (already set in the environment)') +param existingApplicationInsightsConnectionString string = '' + +@description('Existing Application Insights resource ID (already set in the environment)') +param existingApplicationInsightsResourceId string = '' + +@description('Model deployments to create on the existing AI Services account') +param deployments deploymentsType + +@description('List of connections to provision on the existing project') +param connections array = [] + +@secure() +@description('Map of connection name to credentials object. Kept as @secure to prevent secrets from appearing in deployment logs. Example: { "my-conn": { "key": "secret" } }') +param connectionCredentials object = {} + +// Reference the existing account and project — read-only except for the +// additional connections provisioned below from the agent manifest. +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { + name: aiServicesAccountName + + resource project 'projects' existing = { + name: aiFoundryProjectName + } +} + +// Create model deployments on the existing AI Services account. +// Uses @batchSize(1) to avoid concurrent deployment conflicts (same as ai-project.bicep). +@batchSize(1) +resource seqDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-06-01' = [ + for dep in (deployments ?? []): { + parent: aiAccount + name: dep.name + properties: { + model: dep.model + } + sku: dep.sku + } +] + +// Create additional connections from ai.yaml / agent manifest configuration on +// the existing project. Mirrors the loop in ai-project.bicep so manifest-declared +// connections are provisioned regardless of whether the project itself is new or +// pre-existing. +module aiConnections './connection.bicep' = [for (connection, index) in connections: { + name: 'existing-connection-${connection.name}' + params: { + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + connectionConfig: connection + credentials: connectionCredentials[?connection.name] ?? {} + } +}] + +// Outputs — same shape as ai-project.bicep so main.bicep can use either interchangeably +output AZURE_AI_PROJECT_ENDPOINT string = aiAccount::project.properties.endpoints['AI Foundry API'] +output FOUNDRY_PROJECT_ENDPOINT string = aiAccount::project.properties.endpoints['AI Foundry API'] +output AZURE_OPENAI_ENDPOINT string = aiAccount.properties.endpoints['OpenAI Language Model Instance API'] +output aiServicesEndpoint string = aiAccount.properties.endpoint +output accountId string = aiAccount.id +output projectId string = aiAccount::project.id +output aiServicesAccountName string = aiAccount.name +output aiServicesProjectName string = aiAccount::project.name +output aiServicesPrincipalId string = aiAccount.identity.principalId +output projectName string = aiAccount::project.name +output APPLICATIONINSIGHTS_CONNECTION_STRING string = existingApplicationInsightsConnectionString +output APPLICATIONINSIGHTS_RESOURCE_ID string = existingApplicationInsightsResourceId + +// Empty connection outputs — these are already set in the azd environment from init +// Connection outputs from the connections array (provisioned above) +output connectionIds array = [for (connection, index) in (connections ?? []): { + name: aiConnections[index].outputs.connectionName + id: aiConnections[index].outputs.connectionId +}] + +output dependentResources object = { + registry: { + name: '' + loginServer: existingContainerRegistryEndpoint + connectionName: existingAcrConnectionName + } + bing_grounding: { + name: '' + connectionName: '' + connectionId: '' + } + bing_custom_grounding: { + name: '' + connectionName: '' + connectionId: '' + } + search: { + serviceName: '' + connectionName: '' + } + storage: { + accountName: '' + connectionName: '' + } +} + +type deploymentsType = { + @description('Specify the name of cognitive service account deployment.') + name: string + + @description('Required. Properties of Cognitive Services account deployment model.') + model: { + @description('Required. The name of Cognitive Services account deployment model.') + name: string + + @description('Required. The format of Cognitive Services account deployment model.') + format: string + + @description('Required. The version of Cognitive Services account deployment model.') + version: string + } + + @description('The resource model definition representing SKU.') + sku: { + @description('Required. The name of the resource model definition representing SKU.') + name: string + + @description('The capacity of the resource model definition representing SKU.') + capacity: int + } +}[]? diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/host/acr.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/host/acr.bicep new file mode 100644 index 00000000000..f1893d8ff31 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/host/acr.bicep @@ -0,0 +1,88 @@ +targetScope = 'resourceGroup' + +@description('The location used for all deployed resources') +param location string = resourceGroup().location + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Resource name for the container registry') +param resourceName string + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Name for the AI Foundry ACR connection') +param connectionName string + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Create the Container Registry +module containerRegistry 'br/public:avm/res/container-registry/registry:0.1.1' = { + name: 'registry' + params: { + name: resourceName + location: location + tags: tags + publicNetworkAccess: 'Enabled' + roleAssignments:[ + { + principalId: principalId + principalType: principalType + // Container Registry Tasks Contributor — build images with ACR tasks and push container images + roleDefinitionIdOrName: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'fb382eab-e894-4461-af04-94435c366c3f') + } + // TODO SEPARATELY + { + // the foundry project itself can pull from the ACR + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } + ] + } +} + +// Create the ACR connection using the centralized connection module +module acrConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'acr-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'ContainerRegistry' + target: containerRegistry.outputs.loginServer + authType: 'ManagedIdentity' + isSharedToAll: true + metadata: { + ResourceId: containerRegistry.outputs.resourceId + } + } + credentials: { + clientId: aiAccount::aiProject.identity.principalId + resourceId: containerRegistry.outputs.resourceId + } + } +} + +output containerRegistryName string = containerRegistry.outputs.name +output containerRegistryLoginServer string = containerRegistry.outputs.loginServer +output containerRegistryResourceId string = containerRegistry.outputs.resourceId +output containerRegistryConnectionName string = acrConnection.outputs.connectionName diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/applicationinsights-dashboard.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/applicationinsights-dashboard.bicep new file mode 100644 index 00000000000..d082e668ed9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/applicationinsights-dashboard.bicep @@ -0,0 +1,1236 @@ +metadata description = 'Creates a dashboard for an Application Insights instance.' +param name string +param applicationInsightsName string +param location string = resourceGroup().location +param tags object = {} + +// 2020-09-01-preview because that is the latest valid version +resource applicationInsightsDashboard 'Microsoft.Portal/dashboards@2020-09-01-preview' = { + name: name + location: location + tags: tags + properties: { + lenses: [ + { + order: 0 + parts: [ + { + position: { + x: 0 + y: 0 + colSpan: 2 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'id' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AspNetOverviewPinnedPart' + asset: { + idInputName: 'id' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'overview' + } + } + { + position: { + x: 2 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/ProactiveDetectionAsyncPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'ProactiveDetection' + } + } + { + position: { + x: 3 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/QuickPulseButtonSmallPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 4 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-04T01:20:33.345Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AvailabilityNavButtonPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 5 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-08T18:47:35.237Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'ConfigurationId' + value: '78ce933e-e864-4b05-a27b-71fd55a6afad' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AppMapButtonPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 0 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Usage' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 3 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-04T01:22:35.782Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/UsageUsersOverviewPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 4 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Reliability' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 7 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'DataModel' + value: { + version: '1.0.0' + timeContext: { + durationMs: 86400000 + createdTime: '2018-05-04T23:42:40.072Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + isOptional: true + } + { + name: 'ConfigurationId' + value: '8a02f7bf-ac0f-40e1-afe9-f0e72cfee77f' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/CuratedBladeFailuresPinnedPart' + isAdapter: true + asset: { + idInputName: 'ResourceId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'failures' + } + } + { + position: { + x: 8 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Responsiveness\r\n' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 11 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'DataModel' + value: { + version: '1.0.0' + timeContext: { + durationMs: 86400000 + createdTime: '2018-05-04T23:43:37.804Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + isOptional: true + } + { + name: 'ConfigurationId' + value: '2a8ede4f-2bee-4b9c-aed9-2db0e8a01865' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/CuratedBladePerformancePinnedPart' + isAdapter: true + asset: { + idInputName: 'ResourceId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'performance' + } + } + { + position: { + x: 12 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Browser' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 15 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'MetricsExplorerJsonDefinitionId' + value: 'BrowserPerformanceTimelineMetrics' + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + createdTime: '2018-05-08T12:16:27.534Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'CurrentFilter' + value: { + eventTypes: [ + 4 + 1 + 3 + 5 + 2 + 6 + 13 + ] + typeFacets: {} + isPermissive: false + } + } + { + name: 'id' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/MetricsExplorerBladePinnedPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'browser' + } + } + { + position: { + x: 0 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'sessions/count' + aggregationType: 5 + namespace: 'microsoft.insights/components/kusto' + metricVisualization: { + displayName: 'Sessions' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'users/count' + aggregationType: 5 + namespace: 'microsoft.insights/components/kusto' + metricVisualization: { + displayName: 'Users' + color: '#7E58FF' + } + } + ] + title: 'Unique sessions and users' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'segmentationUsers' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'requests/failed' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Failed requests' + color: '#EC008C' + } + } + ] + title: 'Failed requests' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'failures' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'requests/duration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Server response time' + color: '#00BCF2' + } + } + ] + title: 'Server response time' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'performance' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 12 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/networkDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Page load network connect time' + color: '#7E58FF' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/processingDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Client processing time' + color: '#44F1C8' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/sendDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Send request time' + color: '#EB9371' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/receiveDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Receiving response time' + color: '#0672F1' + } + } + ] + title: 'Average page load time breakdown' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 0 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'availabilityResults/availabilityPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Availability' + color: '#47BDF5' + } + } + ] + title: 'Average availability' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'availability' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'exceptions/server' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Server exceptions' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'dependencies/failed' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Dependency failures' + color: '#7E58FF' + } + } + ] + title: 'Server exceptions and Dependency failures' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processorCpuPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Processor time' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processCpuPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Process CPU' + color: '#7E58FF' + } + } + ] + title: 'Average processor and process CPU utilization' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 12 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'exceptions/browser' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Browser exceptions' + color: '#47BDF5' + } + } + ] + title: 'Browser exceptions' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 0 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'availabilityResults/count' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Availability test results count' + color: '#47BDF5' + } + } + ] + title: 'Availability test results count' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processIOBytesPerSecond' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Process IO rate' + color: '#47BDF5' + } + } + ] + title: 'Average process I/O rate' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/memoryAvailableBytes' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Available memory' + color: '#47BDF5' + } + } + ] + title: 'Average available memory' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + ] + } + ] + } +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: applicationInsightsName +} diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/applicationinsights.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/applicationinsights.bicep new file mode 100644 index 00000000000..73240d1b1c9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/applicationinsights.bicep @@ -0,0 +1,47 @@ +metadata description = 'Creates an Application Insights instance based on an existing Log Analytics workspace.' +param name string +param dashboardName string = '' +param location string = resourceGroup().location +param tags object = {} +param logAnalyticsWorkspaceId string + +@description('Optional. Principal ID of the Foundry Project managed identity to grant Log Analytics Reader.') +param projectMIPrincipalId string = '' + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { + name: name + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: logAnalyticsWorkspaceId + } +} + +module applicationInsightsDashboard 'applicationinsights-dashboard.bicep' = if (!empty(dashboardName)) { + name: 'application-insights-dashboard' + params: { + name: dashboardName + location: location + applicationInsightsName: applicationInsights.name + } +} + +// Log Analytics Reader for the Foundry Project managed identity. +// Required for running evaluations on traces generated by agents. +resource logAnalyticsReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(projectMIPrincipalId)) { + scope: applicationInsights + name: guid(applicationInsights.id, projectMIPrincipalId, '73c42c96-874c-492b-b04d-ab87d138a893') + properties: { + principalId: projectMIPrincipalId + principalType: 'ServicePrincipal' + // Log Analytics Reader + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '73c42c96-874c-492b-b04d-ab87d138a893') + } +} + +output connectionString string = applicationInsights.properties.ConnectionString +output id string = applicationInsights.id +output instrumentationKey string = applicationInsights.properties.InstrumentationKey +output name string = applicationInsights.name diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/loganalytics.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/loganalytics.bicep new file mode 100644 index 00000000000..33f9dc29443 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/loganalytics.bicep @@ -0,0 +1,22 @@ +metadata description = 'Creates a Log Analytics workspace.' +param name string +param location string = resourceGroup().location +param tags object = {} + +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { + name: name + location: location + tags: tags + properties: any({ + retentionInDays: 30 + features: { + searchVersion: 1 + } + sku: { + name: 'PerGB2018' + } + }) +} + +output id string = logAnalytics.id +output name string = logAnalytics.name diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/azure_ai_search.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/azure_ai_search.bicep new file mode 100644 index 00000000000..7bb8e635002 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/azure_ai_search.bicep @@ -0,0 +1,211 @@ +targetScope = 'resourceGroup' + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Azure Search resource name') +param resourceName string + +@description('Azure Search SKU name') +param azureSearchSkuName string = 'basic' + +@description('Azure storage account resource ID') +param storageAccountResourceId string + +@description('container name') +param containerName string = 'knowledgebase' + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('Name for the AI Foundry search connection') +param connectionName string + +@description('Location for all resources') +param location string = resourceGroup().location + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Azure Search Service +resource searchService 'Microsoft.Search/searchServices@2024-06-01-preview' = { + name: resourceName + location: location + tags: tags + sku: { + name: azureSearchSkuName + } + identity: { + type: 'SystemAssigned' + } + properties: { + replicaCount: 1 + partitionCount: 1 + hostingMode: 'default' + authOptions: { + aadOrApiKey: { + aadAuthFailureMode: 'http401WithBearerChallenge' + } + } + disableLocalAuth: false + encryptionWithCmk: { + enforcement: 'Unspecified' + } + publicNetworkAccess: 'enabled' + } +} + +// Reference to existing Storage Account +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { + name: last(split(storageAccountResourceId, '/')) +} + +// Reference to existing Blob Service +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' existing = { + parent: storageAccount + name: 'default' +} + +// Storage Container (create if it doesn't exist) +resource storageContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { + parent: blobService + name: containerName + properties: { + publicAccess: 'None' + } +} + +// RBAC Assignments + +// Search needs to read from Storage +resource searchToStorageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, searchService.id, 'Storage Blob Data Reader', uniqueString(deployment().name)) + scope: storageAccount + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1') // Storage Blob Data Reader + principalId: searchService.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// Search needs OpenAI access (AI Services account) +resource searchToAIServicesRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName)) { + name: guid(aiServicesAccountName, searchService.id, 'Cognitive Services OpenAI User', uniqueString(deployment().name)) + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd') // Cognitive Services OpenAI User + principalId: searchService.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// AI Project needs Search access - Service Contributor +resource aiServicesToSearchServiceRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: guid(searchService.id, aiServicesAccountName, aiProjectName, 'Search Service Contributor', uniqueString(deployment().name)) + scope: searchService + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0') // Search Service Contributor + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// AI Project needs Search access - Index Data Contributor +resource aiServicesToSearchDataRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: guid(searchService.id, aiServicesAccountName, aiProjectName, 'Search Index Data Contributor', uniqueString(deployment().name)) + scope: searchService + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7') // Search Index Data Contributor + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// User permissions - Search Index Data Contributor +resource userToSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(searchService.id, principalId, 'Search Index Data Contributor', uniqueString(deployment().name)) + scope: searchService + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7') // Search Index Data Contributor + principalId: principalId + principalType: principalType + } +} + +// // User permissions - Storage Blob Data Contributor +// resource userToStorageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { +// name: guid(storageAccount.id, principalId, 'Storage Blob Data Contributor', uniqueString(deployment().name)) +// scope: storageAccount +// properties: { +// roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor +// principalId: principalId +// principalType: principalType +// } +// } + +// // Project needs Search access - Index Data Contributor +// resource projectToSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { +// name: guid(searchService.id, aiProjectName, 'Search Index Data Contributor', uniqueString(deployment().name)) +// scope: searchService +// properties: { +// roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7') // Search Index Data Contributor +// principalId: aiAccountPrincipalId // Using AI account principal ID as project identity +// principalType: 'ServicePrincipal' +// } +// } + +// Create the AI Search connection using the centralized connection module +module aiSearchConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'ai-search-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'CognitiveSearch' + target: 'https://${searchService.name}.search.windows.net' + authType: 'AAD' + isSharedToAll: true + metadata: { + ApiVersion: '2024-07-01' + ResourceId: searchService.id + ApiType: 'Azure' + type: 'azure_ai_search' + } + } + } + dependsOn: [ + aiServicesToSearchDataRoleAssignment + ] +} + +// Outputs +output searchServiceName string = searchService.name +output searchServiceId string = searchService.id +output searchServicePrincipalId string = searchService.identity.principalId +output storageAccountName string = storageAccount.name +output storageAccountId string = storageAccount.id +output containerName string = storageContainer.name +output storageAccountPrincipalId string = storageAccount.identity.principalId +output searchConnectionName string = (!empty(aiServicesAccountName) && !empty(aiProjectName)) ? aiSearchConnection!.outputs.connectionName : '' +output searchConnectionId string = (!empty(aiServicesAccountName) && !empty(aiProjectName)) ? aiSearchConnection!.outputs.connectionId : '' + diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/bing_custom_grounding.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/bing_custom_grounding.bicep new file mode 100644 index 00000000000..1fddea079e2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/bing_custom_grounding.bicep @@ -0,0 +1,84 @@ +targetScope = 'resourceGroup' + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Bing custom grounding resource name') +param resourceName string + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Name for the AI Foundry Bing Custom Search connection') +param connectionName string + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Bing Search resource for grounding capability +resource bingCustomSearch 'Microsoft.Bing/accounts@2020-06-10' = { + name: resourceName + location: 'global' + tags: tags + sku: { + name: 'G1' + } + properties: { + statisticsEnabled: false + } + kind: 'Bing.CustomGrounding' +} + +// Role assignment to allow AI project to use Bing Search +resource bingCustomSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + scope: bingCustomSearch + name: guid(subscription().id, resourceGroup().id, 'bing-search-role', aiServicesAccountName, aiProjectName) + properties: { + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908') // Cognitive Services User + } +} + +// Create the Bing Custom Search connection using the centralized connection module +module aiSearchConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'bing-custom-search-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'GroundingWithCustomSearch' + target: bingCustomSearch.properties.endpoint + authType: 'ApiKey' + isSharedToAll: true + metadata: { + Location: 'global' + ResourceId: bingCustomSearch.id + ApiType: 'Azure' + type: 'bing_custom_search' + } + } + credentials: { + key: bingCustomSearch.listKeys().key1 + } + } + dependsOn: [ + bingCustomSearchRoleAssignment + ] +} + +// Outputs +output bingCustomGroundingName string = bingCustomSearch.name +output bingCustomGroundingConnectionName string = aiSearchConnection.outputs.connectionName +output bingCustomGroundingResourceId string = bingCustomSearch.id +output bingCustomGroundingConnectionId string = aiSearchConnection.outputs.connectionId diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/bing_grounding.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/bing_grounding.bicep new file mode 100644 index 00000000000..20ea5e9f160 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/bing_grounding.bicep @@ -0,0 +1,83 @@ +targetScope = 'resourceGroup' + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Bing grounding resource name') +param resourceName string + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Name for the AI Foundry Bing Search connection') +param connectionName string + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Bing Search resource for grounding capability +resource bingSearch 'Microsoft.Bing/accounts@2020-06-10' = { + name: resourceName + location: 'global' + tags: tags + sku: { + name: 'G1' + } + properties: { + statisticsEnabled: false + } + kind: 'Bing.Grounding' +} + +// Role assignment to allow AI project to use Bing Search +resource bingSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + scope: bingSearch + name: guid(subscription().id, resourceGroup().id, 'bing-search-role', aiServicesAccountName, aiProjectName) + properties: { + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908') // Cognitive Services User + } +} + +// Create the Bing Search connection using the centralized connection module +module bingSearchConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'bing-search-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'GroundingWithBingSearch' + target: bingSearch.properties.endpoint + authType: 'ApiKey' + isSharedToAll: true + metadata: { + Location: 'global' + ResourceId: bingSearch.id + ApiType: 'Azure' + type: 'bing_grounding' + } + } + credentials: { + key: bingSearch.listKeys().key1 + } + } + dependsOn: [ + bingSearchRoleAssignment + ] +} + +output bingGroundingName string = bingSearch.name +output bingGroundingConnectionName string = bingSearchConnection.outputs.connectionName +output bingGroundingResourceId string = bingSearch.id +output bingGroundingConnectionId string = bingSearchConnection.outputs.connectionId diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/storage/storage.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/storage/storage.bicep new file mode 100644 index 00000000000..18d9535dcd0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/storage/storage.bicep @@ -0,0 +1,113 @@ +targetScope = 'resourceGroup' + +@description('The location used for all deployed resources') +param location string = resourceGroup().location + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Storage account resource name') +param resourceName string + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Name for the AI Foundry storage connection') +param connectionName string + +// Storage Account for the AI Services account +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: resourceName + location: location + tags: tags + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + identity: { + type: 'SystemAssigned' + } + properties: { + supportsHttpsTrafficOnly: true + allowBlobPublicAccess: false + minimumTlsVersion: 'TLS1_2' + accessTier: 'Hot' + encryption: { + services: { + blob: { + enabled: true + } + file: { + enabled: true + } + } + keySource: 'Microsoft.Storage' + } + } +} + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Role assignment for AI Services to access the storage account +resource storageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: guid(storageAccount.id, aiAccount.id, 'ai-storage-contributor') + scope: storageAccount + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// User permissions - Storage Blob Data Contributor +resource userStorageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, principalId, 'Storage Blob Data Contributor') + scope: storageAccount + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor + principalId: principalId + principalType: principalType + } +} + +// Create the storage connection using the centralized connection module +module storageConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'storage-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'AzureStorageAccount' + target: storageAccount.properties.primaryEndpoints.blob + authType: 'AAD' + isSharedToAll: true + metadata: { + ApiType: 'Azure' + ResourceId: storageAccount.id + location: storageAccount.location + } + } + } +} + +output storageAccountName string = storageAccount.name +output storageAccountId string = storageAccount.id +output storageAccountPrincipalId string = storageAccount.identity.principalId +output storageConnectionName string = storageConnection.outputs.connectionName diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/main.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/main.bicep new file mode 100644 index 00000000000..ed4572c1622 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/main.bicep @@ -0,0 +1,248 @@ +targetScope = 'subscription' +// targetScope = 'resourceGroup' + +@minLength(1) +@maxLength(64) +@description('Name of the environment that can be used as part of naming resource convention') +param environmentName string + +@minLength(1) +@maxLength(90) +@description('Name of the resource group to use or create') +param resourceGroupName string = 'rg-${environmentName}' + +// Restricted locations to match list from +// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses?tabs=python-key#region-availability +@minLength(1) +@description('Primary location for all resources') +@allowed([ + 'australiaeast' + 'brazilsouth' + 'canadacentral' + 'canadaeast' + 'eastus' + 'eastus2' + 'francecentral' + 'germanywestcentral' + 'italynorth' + 'japaneast' + 'koreacentral' + 'northcentralus' + 'norwayeast' + 'polandcentral' + 'southafricanorth' + 'southcentralus' + 'southeastasia' + 'southindia' + 'spaincentral' + 'swedencentral' + 'switzerlandnorth' + 'uaenorth' + 'uksouth' + 'westus' + 'westus2' + 'westus3' +]) +param location string + +param aiDeploymentsLocation string = location + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('Optional salt to diversify resource names across project recreations') +param resourceTokenSalt string = '' + +@description('Optional. Name of an existing AI Services account within the resource group. If not provided, a new one will be created.') +param aiFoundryResourceName string = '' + +@description('Optional. Name of the AI Foundry project. If not provided, a default name will be used.') +param aiFoundryProjectName string = 'ai-project-${environmentName}' + +@description('List of model deployments') +param aiProjectDeploymentsJson string = '[]' + +@description('List of connections') +param aiProjectConnectionsJson string = '[]' + +@secure() +@description('JSON map of connection name to credentials object. Example: {"my-conn":{"key":"secret"}}') +param aiProjectConnectionCredentialsJson string = '{}' + +@description('List of resources to create and connect to the AI project') +param aiProjectDependentResourcesJson string = '[]' + +var aiProjectDeployments = json(aiProjectDeploymentsJson) +var aiProjectConnections = json(aiProjectConnectionsJson) +var aiProjectConnectionCreds = json(aiProjectConnectionCredentialsJson) +var aiProjectDependentResources = json(aiProjectDependentResourcesJson) + +@description('Enable hosted agent deployment') +param enableHostedAgents bool + +@description('Enable the capability host for supporting BYO storage of agent conversations. When false and hosted agents are enabled, the capability host is not created.') +param enableCapabilityHost bool + +@description('Enable monitoring for the AI project') +param enableMonitoring bool + +@description('When true, skip Foundry project/role/connection provisioning and reference the existing project read-only. Use when pointing at an existing Foundry project via --project-id.') +param useExistingAiProject bool = false + +@description('Optional. Existing container registry resource ID. If provided, no new ACR will be created and a connection to this ACR will be established.') +param existingContainerRegistryResourceId string = '' + +@description('Optional. Existing container registry endpoint (login server). Required if existingContainerRegistryResourceId is provided.') +param existingContainerRegistryEndpoint string = '' + +@description('Optional. Name of an existing ACR connection on the Foundry project. If provided, no new ACR or connection will be created.') +param existingAcrConnectionName string = '' + +@description('Optional. Skip ACR creation entirely (e.g. for code-deploy scenarios where no container registry is needed). Defaults to false for backward compatibility.') +param skipAcr bool = false + +@description('Optional. Existing Application Insights connection string. If provided, a connection will be created but no new App Insights resource.') +param existingApplicationInsightsConnectionString string = '' + +@description('Optional. Existing Application Insights resource ID. Used for connection metadata when providing an existing App Insights.') +param existingApplicationInsightsResourceId string = '' + +@description('Optional. Name of an existing Application Insights connection on the Foundry project. If provided, no new App Insights or connection will be created.') +param existingAppInsightsConnectionName string = '' + +// Tags that should be applied to all resources. +// +// Note that 'azd-service-name' tags should be applied separately to service host resources. +// Example usage: +// tags: union(tags, { 'azd-service-name': }) +var tags = { + 'azd-env-name': environmentName +} + +// Check if resource group exists and create it if it doesn't +resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: resourceGroupName + location: location + tags: tags +} + +// Build dependent resources array conditionally +// Check if ACR already exists in the user-provided array to avoid duplicates +// Also skip if user provided an existing container registry endpoint or connection name +var hasAcr = contains(map(aiProjectDependentResources, r => r.resource), 'registry') +var shouldCreateAcr = !skipAcr && enableHostedAgents && !hasAcr && empty(existingContainerRegistryResourceId) && empty(existingAcrConnectionName) +var dependentResources = shouldCreateAcr ? union(aiProjectDependentResources, [ + { + resource: 'registry' + connectionName: 'acr-${uniqueString(subscription().id, resourceGroupName, location)}' + } +]) : aiProjectDependentResources + +// AI Project module — only when creating new resources +module aiProject 'core/ai/ai-project.bicep' = if (!useExistingAiProject) { + scope: rg + name: 'ai-project' + params: { + tags: tags + location: aiDeploymentsLocation + aiFoundryProjectName: aiFoundryProjectName + principalId: principalId + principalType: principalType + existingAiAccountName: aiFoundryResourceName + deployments: aiProjectDeployments + connections: aiProjectConnections + connectionCredentials: aiProjectConnectionCreds + additionalDependentResources: dependentResources + enableMonitoring: enableMonitoring + enableHostedAgents: enableHostedAgents + enableCapabilityHost: enableCapabilityHost + existingContainerRegistryResourceId: existingContainerRegistryResourceId + existingContainerRegistryEndpoint: existingContainerRegistryEndpoint + existingAcrConnectionName: existingAcrConnectionName + existingApplicationInsightsConnectionString: existingApplicationInsightsConnectionString + existingApplicationInsightsResourceId: existingApplicationInsightsResourceId + existingAppInsightsConnectionName: existingAppInsightsConnectionName + resourceTokenSalt: resourceTokenSalt + } +} + +// Existing project module — read-only reference when reusing an existing Foundry project +module existingAiProject 'core/ai/existing-ai-project.bicep' = if (useExistingAiProject) { + scope: rg + name: 'existing-ai-project' + params: { + aiServicesAccountName: aiFoundryResourceName + aiFoundryProjectName: aiFoundryProjectName + deployments: aiProjectDeployments + existingAcrConnectionName: existingAcrConnectionName + existingContainerRegistryEndpoint: existingContainerRegistryEndpoint + existingApplicationInsightsConnectionString: existingApplicationInsightsConnectionString + existingApplicationInsightsResourceId: existingApplicationInsightsResourceId + connections: aiProjectConnections + connectionCredentials: aiProjectConnectionCreds + } +} + +// ACR for existing project — create when hosted agents need a registry but the existing project has none +var shouldCreateAcrForExistingProject = useExistingAiProject && shouldCreateAcr +var acrConnectionName = 'acr-${uniqueString(subscription().id, resourceGroupName, location)}' + +module acrForExistingProject 'core/host/acr.bicep' = if (shouldCreateAcrForExistingProject) { + scope: rg + name: 'acr-for-existing-project' + params: { + location: location + tags: tags + resourceName: 'cr${uniqueString(subscription().id, resourceGroupName, location)}' + connectionName: acrConnectionName + principalId: principalId + principalType: principalType + aiServicesAccountName: aiFoundryResourceName + aiProjectName: aiFoundryProjectName + } +} + +// Resources +output AZURE_RESOURCE_GROUP string = resourceGroupName +output AZURE_AI_ACCOUNT_ID string = useExistingAiProject ? existingAiProject.outputs.accountId : aiProject.outputs.accountId +output AZURE_AI_PROJECT_ID string = useExistingAiProject ? existingAiProject.outputs.projectId : aiProject.outputs.projectId +output AZURE_AI_FOUNDRY_PROJECT_ID string = useExistingAiProject ? existingAiProject.outputs.projectId : aiProject.outputs.projectId +output AZURE_AI_ACCOUNT_NAME string = useExistingAiProject ? existingAiProject.outputs.aiServicesAccountName : aiProject.outputs.aiServicesAccountName +output AZURE_AI_PROJECT_NAME string = useExistingAiProject ? existingAiProject.outputs.projectName : aiProject.outputs.projectName + +// Endpoints +output AZURE_AI_PROJECT_ENDPOINT string = useExistingAiProject ? existingAiProject.outputs.AZURE_AI_PROJECT_ENDPOINT : aiProject.outputs.AZURE_AI_PROJECT_ENDPOINT +output FOUNDRY_PROJECT_ENDPOINT string = useExistingAiProject ? existingAiProject.outputs.FOUNDRY_PROJECT_ENDPOINT : aiProject.outputs.FOUNDRY_PROJECT_ENDPOINT +output AZURE_OPENAI_ENDPOINT string = useExistingAiProject ? existingAiProject.outputs.AZURE_OPENAI_ENDPOINT : aiProject.outputs.AZURE_OPENAI_ENDPOINT +output APPLICATIONINSIGHTS_CONNECTION_STRING string = useExistingAiProject ? existingAiProject.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING : aiProject.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING +output APPLICATIONINSIGHTS_RESOURCE_ID string = useExistingAiProject ? existingAiProject.outputs.APPLICATIONINSIGHTS_RESOURCE_ID : aiProject.outputs.APPLICATIONINSIGHTS_RESOURCE_ID + +// Dependent Resources and Connections + +// ACR +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = shouldCreateAcrForExistingProject ? acrForExistingProject.outputs.containerRegistryConnectionName : (useExistingAiProject ? existingAiProject.outputs.dependentResources.registry.connectionName : aiProject.outputs.dependentResources.registry.connectionName) +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = shouldCreateAcrForExistingProject ? acrForExistingProject.outputs.containerRegistryLoginServer : (useExistingAiProject ? existingAiProject.outputs.dependentResources.registry.loginServer : aiProject.outputs.dependentResources.registry.loginServer) + +// Bing Search +output BING_GROUNDING_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_grounding.connectionName : aiProject.outputs.dependentResources.bing_grounding.connectionName +output BING_GROUNDING_RESOURCE_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_grounding.name : aiProject.outputs.dependentResources.bing_grounding.name +output BING_GROUNDING_CONNECTION_ID string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_grounding.connectionId : aiProject.outputs.dependentResources.bing_grounding.connectionId + +// Bing Custom Search +output BING_CUSTOM_GROUNDING_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_custom_grounding.connectionName : aiProject.outputs.dependentResources.bing_custom_grounding.connectionName +output BING_CUSTOM_GROUNDING_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_custom_grounding.name : aiProject.outputs.dependentResources.bing_custom_grounding.name +output BING_CUSTOM_GROUNDING_CONNECTION_ID string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_custom_grounding.connectionId : aiProject.outputs.dependentResources.bing_custom_grounding.connectionId + +// Azure AI Search +output AZURE_AI_SEARCH_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.search.connectionName : aiProject.outputs.dependentResources.search.connectionName +output AZURE_AI_SEARCH_SERVICE_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.search.serviceName : aiProject.outputs.dependentResources.search.serviceName + +// Azure Storage +output AZURE_STORAGE_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.storage.connectionName : aiProject.outputs.dependentResources.storage.connectionName +output AZURE_STORAGE_ACCOUNT_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.storage.accountName : aiProject.outputs.dependentResources.storage.accountName + +// Connections +output AI_PROJECT_CONNECTION_IDS_JSON string = useExistingAiProject ? string(existingAiProject.outputs.connectionIds) : string(aiProject.outputs.connectionIds) diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/main.parameters.json b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/main.parameters.json new file mode 100644 index 00000000000..0d0109fe4a8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/main.parameters.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceGroupName": { + "value": "${AZURE_RESOURCE_GROUP}" + }, + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "aiFoundryResourceName": { + "value": "${AZURE_AI_ACCOUNT_NAME}" + }, + "aiFoundryProjectName": { + "value": "${AZURE_AI_PROJECT_NAME}" + }, + "aiDeploymentsLocation": { + "value": "${AZURE_AI_DEPLOYMENTS_LOCATION}" + }, + "resourceTokenSalt": { + "value": "${AZD_RESOURCE_TOKEN_SALT=}" + }, + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "principalType": { + "value": "${AZURE_PRINCIPAL_TYPE}" + }, + "aiProjectDeploymentsJson": { + "value": "${AI_PROJECT_DEPLOYMENTS=[]}" + }, + "aiProjectConnectionsJson": { + "value": "${AI_PROJECT_CONNECTIONS=[]}" + }, + "aiProjectConnectionCredentialsJson": { + "value": "${AI_PROJECT_CONNECTION_CREDENTIALS}" + }, + "aiProjectDependentResourcesJson": { + "value": "${AI_PROJECT_DEPENDENT_RESOURCES=[]}" + }, + "enableMonitoring": { + "value": "${ENABLE_MONITORING=true}" + }, + "enableHostedAgents": { + "value": "${ENABLE_HOSTED_AGENTS=false}" + }, + "enableCapabilityHost": { + "value": "${ENABLE_CAPABILITY_HOST=true}" + }, + "useExistingAiProject": { + "value": "${USE_EXISTING_AI_PROJECT=false}" + }, + "existingContainerRegistryResourceId": { + "value": "${AZURE_CONTAINER_REGISTRY_RESOURCE_ID=}" + }, + "existingContainerRegistryEndpoint": { + "value": "${AZURE_CONTAINER_REGISTRY_ENDPOINT=}" + }, + "existingAcrConnectionName": { + "value": "${AZURE_AI_PROJECT_ACR_CONNECTION_NAME=}" + }, + "skipAcr": { + "value": "${AZD_AGENT_SKIP_ACR=false}" + }, + "existingApplicationInsightsConnectionString": { + "value": "${APPLICATIONINSIGHTS_CONNECTION_STRING=}" + }, + "existingApplicationInsightsResourceId": { + "value": "${APPLICATIONINSIGHTS_RESOURCE_ID=}" + }, + "existingAppInsightsConnectionName": { + "value": "${APPLICATIONINSIGHTS_CONNECTION_NAME=}" + } + } +} From 1be4f1c885a66b6702b4c5582010870a057620a2 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Thu, 25 Jun 2026 23:38:23 +0530 Subject: [PATCH 02/24] 0.1.42-preview dev release --- .../extensions/azure.ai.agents/extension.yaml | 2 +- .../extensions/azure.ai.agents/version.txt | 2 +- .../internal/github/github.go | 32 +- cli/azd/extensions/registry.json | 356 +++++++++++------- 4 files changed, 251 insertions(+), 141 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index b2957cc72f2..ec8959e1fd0 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -5,7 +5,7 @@ displayName: Foundry agents (Preview) description: Ship agents with Microsoft Foundry from your terminal. (Preview) usage: azd ai agent [options] # NOTE: Make sure version.txt is in sync with this version. -version: 0.1.41-preview +version: 0.1.42-preview requiredAzdVersion: ">1.25.2" dependencies: - id: azure.ai.inspector diff --git a/cli/azd/extensions/azure.ai.agents/version.txt b/cli/azd/extensions/azure.ai.agents/version.txt index de4db352fba..d76fe6c36e1 100644 --- a/cli/azd/extensions/azure.ai.agents/version.txt +++ b/cli/azd/extensions/azure.ai.agents/version.txt @@ -1 +1 @@ -0.1.41-preview +0.1.42-preview diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/github/github.go b/cli/azd/extensions/microsoft.azd.extensions/internal/github/github.go index 45c07a64ac8..e8bf55a13b8 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/github/github.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/github/github.go @@ -6,6 +6,7 @@ package github import ( "encoding/json" "fmt" + "os" "os/exec" "runtime" "strings" @@ -170,9 +171,36 @@ func (gh *GitHubCli) CreateRelease(cwd string, tagName string, opts map[string]s // Define boolean flags that should be added without values booleanFlags := map[string]bool{"prerelease": true, "draft": true} - // Add optional arguments (skip boolean flags) + // Release notes can be large (e.g. an entire CHANGELOG.md). Passing them + // inline via "--notes " overflows the command-line length limit on + // Windows (error 206: "The filename or extension is too long."). Spill the + // notes to a temp file and use "--notes-file" instead, which gh reads + // directly. The file is removed after the command runs. + var notesFile string + defer func() { + if notesFile != "" { + _ = os.Remove(notesFile) + } + }() + if notes := opts["notes"]; notes != "" { + f, err := os.CreateTemp("", "azd-release-notes-*.md") + if err != nil { + return nil, fmt.Errorf("failed to create temp file for release notes: %w", err) + } + notesFile = f.Name() + if _, err := f.WriteString(notes); err != nil { + _ = f.Close() + return nil, fmt.Errorf("failed to write release notes to temp file: %w", err) + } + if err := f.Close(); err != nil { + return nil, fmt.Errorf("failed to close release notes temp file: %w", err) + } + args = append(args, "--notes-file", notesFile) + } + + // Add optional arguments (skip boolean flags and notes, which is handled above) for key, value := range opts { - if value != "" && !booleanFlags[key] { + if value != "" && !booleanFlags[key] && key != "notes" { args = append(args, fmt.Sprintf("--%s", key), value) } } diff --git a/cli/azd/extensions/registry.json b/cli/azd/extensions/registry.json index 11d64a3b856..fc0a162aa77 100644 --- a/cli/azd/extensions/registry.json +++ b/cli/azd/extensions/registry.json @@ -12,11 +12,11 @@ "custom-commands", "lifecycle-events" ], - "usage": "azd demo [options]", + "usage": "azd demo \u003ccommand\u003e [options]", "examples": [ { "name": "context", - "description": "Displays the current `azd` project & environment context.", + "description": "Displays the current `azd` project \u0026 environment context.", "usage": "azd demo context" }, { @@ -83,11 +83,11 @@ "lifecycle-events", "mcp-server" ], - "usage": "azd demo [options]", + "usage": "azd demo \u003ccommand\u003e [options]", "examples": [ { "name": "context", - "description": "Displays the current `azd` project & environment context.", + "description": "Displays the current `azd` project \u0026 environment context.", "usage": "azd demo context" }, { @@ -168,11 +168,11 @@ "description": "Deploys application components to demo" } ], - "usage": "azd demo [options]", + "usage": "azd demo \u003ccommand\u003e [options]", "examples": [ { "name": "context", - "description": "Displays the current `azd` project & environment context.", + "description": "Displays the current `azd` project \u0026 environment context.", "usage": "azd demo context" }, { @@ -254,11 +254,11 @@ "description": "Deploys application components to demo" } ], - "usage": "azd demo [options]", + "usage": "azd demo \u003ccommand\u003e [options]", "examples": [ { "name": "context", - "description": "Displays the current `azd` project & environment context.", + "description": "Displays the current `azd` project \u0026 environment context.", "usage": "azd demo context" }, { @@ -340,11 +340,11 @@ "description": "Deploys application components to demo" } ], - "usage": "azd demo [options]", + "usage": "azd demo \u003ccommand\u003e [options]", "examples": [ { "name": "context", - "description": "Displays the current `azd` project & environment context.", + "description": "Displays the current `azd` project \u0026 environment context.", "usage": "azd demo context" }, { @@ -437,7 +437,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd x [options]", + "usage": "azd x \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -521,7 +521,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd x [options]", + "usage": "azd x \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -610,7 +610,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd x [options]", + "usage": "azd x \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -699,7 +699,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd x [options]", + "usage": "azd x \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -788,7 +788,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd x [options]", + "usage": "azd x \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -877,7 +877,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd x [options]", + "usage": "azd x \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -967,7 +967,7 @@ "custom-commands", "metadata" ], - "usage": "azd x [options]", + "usage": "azd x \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -1057,7 +1057,7 @@ "custom-commands", "metadata" ], - "usage": "azd x [options]", + "usage": "azd x \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -1147,7 +1147,7 @@ "custom-commands", "metadata" ], - "usage": "azd x [options]", + "usage": "azd x \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -1237,7 +1237,7 @@ "custom-commands", "metadata" ], - "usage": "azd x [options]", + "usage": "azd x \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -1327,7 +1327,7 @@ "custom-commands", "metadata" ], - "usage": "azd x [options]", + "usage": "azd x \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -1424,7 +1424,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd coding-agent [options]", + "usage": "azd coding-agent \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -1482,7 +1482,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd coding-agent [options]", + "usage": "azd coding-agent \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -1540,7 +1540,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd coding-agent [options]", + "usage": "azd coding-agent \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -1599,7 +1599,7 @@ "custom-commands", "metadata" ], - "usage": "azd coding-agent [options]", + "usage": "azd coding-agent \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -1658,7 +1658,7 @@ "custom-commands", "metadata" ], - "usage": "azd coding-agent [options]", + "usage": "azd coding-agent \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -1734,7 +1734,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -1808,7 +1808,7 @@ "description": "Deploys agents to the Foundry Agent Service." } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -1882,7 +1882,7 @@ "description": "Deploys agents to the Foundry Agent Service." } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -1956,7 +1956,7 @@ "description": "Deploys agents to the Foundry Agent Service." } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -2030,7 +2030,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -2104,7 +2104,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -2178,7 +2178,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -2252,7 +2252,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -2327,7 +2327,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -2402,7 +2402,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -2477,7 +2477,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -2538,7 +2538,7 @@ }, { "version": "0.1.10-preview", - "requiredAzdVersion": ">1.23.4", + "requiredAzdVersion": "\u003e1.23.4", "capabilities": [ "custom-commands", "lifecycle-events", @@ -2553,7 +2553,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -2614,7 +2614,7 @@ }, { "version": "0.1.11-preview", - "requiredAzdVersion": ">1.23.4", + "requiredAzdVersion": "\u003e1.23.4", "capabilities": [ "custom-commands", "lifecycle-events", @@ -2629,7 +2629,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -2690,7 +2690,7 @@ }, { "version": "0.1.12-preview", - "requiredAzdVersion": ">1.23.6", + "requiredAzdVersion": "\u003e1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -2705,7 +2705,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -2766,7 +2766,7 @@ }, { "version": "0.1.13-preview", - "requiredAzdVersion": ">1.23.6", + "requiredAzdVersion": "\u003e1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -2781,7 +2781,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -2842,7 +2842,7 @@ }, { "version": "0.1.14-preview", - "requiredAzdVersion": ">1.23.6", + "requiredAzdVersion": "\u003e1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -2857,7 +2857,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -2918,7 +2918,7 @@ }, { "version": "0.1.15-preview", - "requiredAzdVersion": ">1.23.6", + "requiredAzdVersion": "\u003e1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -2933,7 +2933,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -2994,7 +2994,7 @@ }, { "version": "0.1.16-preview", - "requiredAzdVersion": ">1.23.6", + "requiredAzdVersion": "\u003e1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3009,7 +3009,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -3070,7 +3070,7 @@ }, { "version": "0.1.17-preview", - "requiredAzdVersion": ">1.23.6", + "requiredAzdVersion": "\u003e1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3085,7 +3085,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -3146,7 +3146,7 @@ }, { "version": "0.1.18-preview", - "requiredAzdVersion": ">1.23.6", + "requiredAzdVersion": "\u003e1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3161,7 +3161,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -3222,7 +3222,7 @@ }, { "version": "0.1.19-preview", - "requiredAzdVersion": ">1.23.6", + "requiredAzdVersion": "\u003e1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3237,7 +3237,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -3298,7 +3298,7 @@ }, { "version": "0.1.20-preview", - "requiredAzdVersion": ">1.23.6", + "requiredAzdVersion": "\u003e1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3313,7 +3313,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -3374,7 +3374,7 @@ }, { "version": "0.1.21-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3389,7 +3389,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -3450,7 +3450,7 @@ }, { "version": "0.1.22-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3465,7 +3465,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -3526,7 +3526,7 @@ }, { "version": "0.1.23-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3541,7 +3541,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -3602,7 +3602,7 @@ }, { "version": "0.1.24-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3617,7 +3617,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -3678,7 +3678,7 @@ }, { "version": "0.1.25-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3693,7 +3693,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -3754,7 +3754,7 @@ }, { "version": "0.1.26-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3769,7 +3769,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -3830,7 +3830,7 @@ }, { "version": "0.1.27-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3845,7 +3845,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -3906,7 +3906,7 @@ }, { "version": "0.1.28-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3921,7 +3921,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -3982,7 +3982,7 @@ }, { "version": "0.1.29-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3997,7 +3997,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -4058,7 +4058,7 @@ }, { "version": "0.1.30-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4073,7 +4073,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -4134,7 +4134,7 @@ }, { "version": "0.1.31-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4149,7 +4149,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -4210,7 +4210,7 @@ }, { "version": "0.1.32-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4225,7 +4225,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -4286,7 +4286,7 @@ }, { "version": "0.1.33-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4301,7 +4301,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -4362,7 +4362,7 @@ }, { "version": "0.1.34-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4377,7 +4377,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -4438,7 +4438,7 @@ }, { "version": "0.1.35-preview", - "requiredAzdVersion": ">1.25.2", + "requiredAzdVersion": "\u003e1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4453,7 +4453,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -4520,7 +4520,7 @@ }, { "version": "0.1.36-preview", - "requiredAzdVersion": ">1.25.2", + "requiredAzdVersion": "\u003e1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4535,7 +4535,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -4602,7 +4602,7 @@ }, { "version": "0.1.37-preview", - "requiredAzdVersion": ">1.25.2", + "requiredAzdVersion": "\u003e1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4617,7 +4617,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -4684,7 +4684,7 @@ }, { "version": "0.1.38-preview", - "requiredAzdVersion": ">1.25.2", + "requiredAzdVersion": "\u003e1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4699,7 +4699,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -4766,7 +4766,7 @@ }, { "version": "0.1.39-preview", - "requiredAzdVersion": ">1.25.2", + "requiredAzdVersion": "\u003e1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4781,7 +4781,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -4848,7 +4848,7 @@ }, { "version": "0.1.40-preview", - "requiredAzdVersion": ">1.25.2", + "requiredAzdVersion": "\u003e1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4863,7 +4863,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -4930,7 +4930,7 @@ }, { "version": "0.1.41-preview", - "requiredAzdVersion": ">1.25.2", + "requiredAzdVersion": "\u003e1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4945,7 +4945,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent [options]", + "usage": "azd ai agent \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5009,6 +5009,88 @@ "version": "~0.0.1-preview" } ] + }, + { + "version": "0.1.42-preview", + "requiredAzdVersion": "\u003e1.25.2", + "capabilities": [ + "custom-commands", + "lifecycle-events", + "mcp-server", + "service-target-provider", + "metadata" + ], + "providers": [ + { + "name": "azure.ai.agent", + "type": "service-target", + "description": "Deploys agents to the Foundry Agent Service" + } + ], + "usage": "azd ai agent \u003ccommand\u003e [options]", + "examples": [ + { + "name": "init", + "description": "Initialize a new AI agent project.", + "usage": "azd ai agent init" + } + ], + "artifacts": { + "darwin/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "d926d92f02267ee2756f6224df44faa61d55056e87b2c4bae77a57f898c7cfb7" + }, + "entryPoint": "azure-ai-agents-darwin-amd64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.42-preview/azure-ai-agents-darwin-amd64.zip" + }, + "darwin/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "57a5c7eb462cb7e09e8ad820b786dbcda748f4381918f45c664531d754853500" + }, + "entryPoint": "azure-ai-agents-darwin-arm64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.42-preview/azure-ai-agents-darwin-arm64.zip" + }, + "linux/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "dada19a59ca9383d2e5f62289e24f36763b36594458d961db1e3ef46ea9fe0ea" + }, + "entryPoint": "azure-ai-agents-linux-amd64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.42-preview/azure-ai-agents-linux-amd64.tar.gz" + }, + "linux/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "fee144cf3ce87716335e8d45f892da6bac310c3747366605ca98735977d07d7c" + }, + "entryPoint": "azure-ai-agents-linux-arm64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.42-preview/azure-ai-agents-linux-arm64.tar.gz" + }, + "windows/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "90f07a8e86605364c20e108f5bfda37d1e2857e1d9734ba00a89150771d88468" + }, + "entryPoint": "azure-ai-agents-windows-amd64.exe", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.42-preview/azure-ai-agents-windows-amd64.zip" + }, + "windows/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "30c95b1e3ef67cf0a18c2f93c6f75bc8cca8fff227d8e2835961c07f6b4418c0" + }, + "entryPoint": "azure-ai-agents-windows-arm64.exe", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.42-preview/azure-ai-agents-windows-arm64.zip" + } + }, + "dependencies": [ + { + "id": "azure.ai.inspector", + "version": "~0.0.1-preview" + } + ] } ] }, @@ -5023,7 +5105,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd concurx [options]", + "usage": "azd concurx \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -5082,7 +5164,7 @@ "custom-commands", "metadata" ], - "usage": "azd concurx [options]", + "usage": "azd concurx \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -5141,7 +5223,7 @@ "custom-commands", "metadata" ], - "usage": "azd concurx [options]", + "usage": "azd concurx \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -5207,7 +5289,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5276,7 +5358,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5346,7 +5428,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5416,7 +5498,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5486,7 +5568,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5556,7 +5638,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5626,7 +5708,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5696,7 +5778,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5774,7 +5856,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models [options]", + "usage": "azd ai models \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5859,7 +5941,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models [options]", + "usage": "azd ai models \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5944,7 +6026,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models [options]", + "usage": "azd ai models \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -6029,7 +6111,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models [options]", + "usage": "azd ai models \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -6114,7 +6196,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models [options]", + "usage": "azd ai models \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -6204,7 +6286,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models [options]", + "usage": "azd ai models \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -6302,12 +6384,12 @@ "custom-commands", "metadata" ], - "usage": "azd appservice [options]", + "usage": "azd appservice \u003ccommand\u003e [options]", "examples": [ { "name": "swap", "description": "Swap deployment slots for an App Service.", - "usage": "azd appservice swap --service --src --dst " + "usage": "azd appservice swap --service \u003cservice-name\u003e --src \u003csource-slot\u003e --dst \u003cdestination-slot\u003e" } ], "artifacts": { @@ -6367,12 +6449,12 @@ "custom-commands", "metadata" ], - "usage": "azd appservice [options]", + "usage": "azd appservice \u003ccommand\u003e [options]", "examples": [ { "name": "swap", "description": "Swap deployment slots for an App Service.", - "usage": "azd appservice swap --service --src --dst " + "usage": "azd appservice swap --service \u003cservice-name\u003e --src \u003csource-slot\u003e --dst \u003cdestination-slot\u003e" } ], "artifacts": { @@ -6436,12 +6518,12 @@ "versions": [ { "version": "0.0.1-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "metadata" ], - "usage": "azd ai inspector [options]", + "usage": "azd ai inspector \u003ccommand\u003e [options]", "examples": [ { "name": "launch", @@ -6514,7 +6596,7 @@ "versions": [ { "version": "0.1.0-preview", - "requiredAzdVersion": ">1.25.2", + "requiredAzdVersion": "\u003e1.25.2", "usage": "", "examples": null, "dependencies": [ @@ -6566,7 +6648,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai connection [options]", + "usage": "azd ai connection \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -6625,7 +6707,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai connection [options]", + "usage": "azd ai connection \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -6684,7 +6766,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai connection [options]", + "usage": "azd ai connection \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -6755,7 +6837,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai project [options]", + "usage": "azd ai project \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -6826,7 +6908,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai routine [options]", + "usage": "azd ai routine \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -6893,12 +6975,12 @@ "versions": [ { "version": "0.1.0-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "metadata" ], - "usage": "azd ai skill [options]", + "usage": "azd ai skill \u003ccommand\u003e [options]", "examples": [ { "name": "list", @@ -6969,12 +7051,12 @@ }, { "version": "0.1.1-preview", - "requiredAzdVersion": ">1.23.13", + "requiredAzdVersion": "\u003e1.23.13", "capabilities": [ "custom-commands", "metadata" ], - "usage": "azd ai skill [options]", + "usage": "azd ai skill \u003ccommand\u003e [options]", "examples": [ { "name": "list", @@ -7061,7 +7143,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai toolbox [options]", + "usage": "azd ai toolbox \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -7120,7 +7202,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai toolbox [options]", + "usage": "azd ai toolbox \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -7180,4 +7262,4 @@ ] } ] -} +} \ No newline at end of file From b7402812c647d4ddd4451b8c430f959f91ee7c74 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Thu, 2 Jul 2026 01:57:16 +0530 Subject: [PATCH 03/24] MHA with tools etc --- .../extensions/azure.ai.agents/extension.yaml | 2 +- .../pkg/agents/agent_yaml/managed_test.go | 96 ++ .../internal/pkg/agents/agent_yaml/map.go | 14 + .../internal/pkg/agents/agent_yaml/yaml.go | 19 + .../internal/project/prompt_tools_test.go | 89 ++ .../.gitignore | 0 .../my-prompt-agent-0701-02/agent.yaml | 27 + .../my-prompt-agent-0701-02/azure.yaml | 32 + .../infra/abbreviations.json | 0 .../infra/core/ai/acr-role-assignment.bicep | 0 .../infra/core/ai/ai-project.bicep | 0 .../infra/core/ai/connection.bicep | 0 .../infra/core/ai/existing-ai-project.bicep | 0 .../infra/core/host/acr.bicep | 0 .../applicationinsights-dashboard.bicep | 0 .../core/monitor/applicationinsights.bicep | 0 .../infra/core/monitor/loganalytics.bicep | 0 .../infra/core/search/azure_ai_search.bicep | 0 .../core/search/bing_custom_grounding.bicep | 0 .../infra/core/search/bing_grounding.bicep | 0 .../infra/core/storage/storage.bicep | 0 .../infra/main.bicep | 0 .../infra/main.parameters.json | 0 .../my-prompt-agent-1031-0625/azure.yaml | 12 - .../extensions/azure.ai.agents/version.txt | 2 +- cli/azd/extensions/registry.json | 82 ++ .../generate_getting_started.py | 239 +++++ .../managed-harness-agents/generate_spec.py | 863 ++++++++++++++++++ .../managed-agents-getting-started.docx | Bin 0 -> 39145 bytes .../managed-agents-getting-started.md | 178 ++++ docs/specs/managed-harness-agents/spec.docx | Bin 0 -> 48114 bytes .../~$naged-agents-getting-started.docx | Bin 0 -> 162 bytes 32 files changed, 1641 insertions(+), 14 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_tools_test.go rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/.gitignore (100%) create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/agent.yaml create mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/azure.yaml rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/abbreviations.json (100%) rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/core/ai/acr-role-assignment.bicep (100%) rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/core/ai/ai-project.bicep (100%) rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/core/ai/connection.bicep (100%) rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/core/ai/existing-ai-project.bicep (100%) rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/core/host/acr.bicep (100%) rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/core/monitor/applicationinsights-dashboard.bicep (100%) rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/core/monitor/applicationinsights.bicep (100%) rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/core/monitor/loganalytics.bicep (100%) rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/core/search/azure_ai_search.bicep (100%) rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/core/search/bing_custom_grounding.bicep (100%) rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/core/search/bing_grounding.bicep (100%) rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/core/storage/storage.bicep (100%) rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/main.bicep (100%) rename cli/azd/extensions/azure.ai.agents/{my-prompt-agent-1031-0625 => my-prompt-agent-0701-02}/infra/main.parameters.json (100%) delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/azure.yaml create mode 100644 docs/specs/managed-harness-agents/generate_getting_started.py create mode 100644 docs/specs/managed-harness-agents/generate_spec.py create mode 100644 docs/specs/managed-harness-agents/managed-agents-getting-started.docx create mode 100644 docs/specs/managed-harness-agents/managed-agents-getting-started.md create mode 100644 docs/specs/managed-harness-agents/spec.docx create mode 100644 docs/specs/managed-harness-agents/~$naged-agents-getting-started.docx diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index ec8959e1fd0..76ac370f15e 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -5,7 +5,7 @@ displayName: Foundry agents (Preview) description: Ship agents with Microsoft Foundry from your terminal. (Preview) usage: azd ai agent [options] # NOTE: Make sure version.txt is in sync with this version. -version: 0.1.42-preview +version: 0.1.43-preview requiredAzdVersion: ">1.25.2" dependencies: - id: azure.ai.inspector diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go index 564b81cf4e0..536176f185b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go @@ -183,3 +183,99 @@ func TestCreateManagedAgentAPIRequest_SetsHarness(t *testing.T) { t.Errorf("serialized request missing harness field:\n%s", data) } } + +// TestCreateManagedAgentAPIRequest_ToolsPassthrough verifies that tools, +// tool_choice, and structured_inputs authored in agent.yaml flow through +// verbatim into the create request definition and are serialized with the +// API's snake_case shape. +func TestCreateManagedAgentAPIRequest_ToolsPassthrough(t *testing.T) { + yamlContent := []byte(` +kind: managed +name: kitchen-sink-agent +model: gpt-4o +instructions: You are a maximally capable assistant. +tool_choice: auto +structured_inputs: + user_context: + description: Extra context supplied per invocation + required: false +tools: + - type: function + name: calculate_sum + description: Adds two numbers + parameters: + type: object + properties: + a: { type: number } + b: { type: number } + required: [a, b] + strict: true + - type: code_interpreter + container: auto + - type: file_search + vector_store_ids: [vs_12345] + max_num_results: 10 + - type: mcp + server_label: github-mcp + server_url: https://api.githubcopilot.com/mcp + require_approval: always + - type: azure_ai_search + azure_ai_search: + index_name: my-index + - type: bing_grounding + bing_grounding: + search_configurations: + - project_connection_id: conn_bing_456 + - type: toolbox_search_preview +`) + + var managed ManagedAgent + if err := yaml.Unmarshal(yamlContent, &managed); err != nil { + t.Fatalf("unmarshal managed agent: %v", err) + } + if len(managed.Tools) != 7 { + t.Fatalf("tools: got %d entries, want 7", len(managed.Tools)) + } + + req, err := CreateManagedAgentAPIRequest(managed, nil) + if err != nil { + t.Fatalf("CreateManagedAgentAPIRequest: %v", err) + } + + def, ok := req.Definition.(agent_api.ManagedAgentDefinition) + if !ok { + t.Fatalf("definition: got %T, want agent_api.ManagedAgentDefinition", req.Definition) + } + if len(def.Tools) != 7 { + t.Errorf("definition tools: got %d, want 7", len(def.Tools)) + } + if def.ToolChoice != "auto" { + t.Errorf("tool_choice: got %v, want auto", def.ToolChoice) + } + if _, ok := def.StructuredInputs["user_context"]; !ok { + t.Errorf("structured_inputs missing user_context: %+v", def.StructuredInputs) + } + + // The serialized body must carry the verbatim snake_case tool shapes. + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + body := string(data) + for _, want := range []string{ + `"tool_choice":"auto"`, + `"structured_inputs"`, + `"type":"function"`, + `"type":"code_interpreter"`, + `"type":"mcp"`, + `"server_label":"github-mcp"`, + `"type":"azure_ai_search"`, + `"type":"bing_grounding"`, + `"type":"toolbox_search_preview"`, + `"vector_store_ids"`, + } { + if !strings.Contains(body, want) { + t.Errorf("serialized request missing %s:\n%s", want, body) + } + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index 91fd39f6748..bdabb491462 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -482,6 +482,20 @@ func CreateManagedAgentAPIRequest( managedDef.Skills = append([]string(nil), managedAgent.Skills...) } + // Tools, tool_choice, and structured_inputs are passed through verbatim so + // authors can express any tool type the managed-agent API accepts without + // this layer having to model each one. The YAML is decoded into + // JSON-compatible values (maps/slices/scalars) and re-serialized as-is. + if len(managedAgent.Tools) > 0 { + managedDef.Tools = managedAgent.Tools + } + if managedAgent.ToolChoice != nil { + managedDef.ToolChoice = managedAgent.ToolChoice + } + if len(managedAgent.StructuredInputs) > 0 { + managedDef.StructuredInputs = managedAgent.StructuredInputs + } + // Build-time environment variables (if supplied) get carried into the // managed environment block so the Hand sandbox can read them. if buildConfig != nil && len(buildConfig.EnvironmentVariables) > 0 { diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index 7893c66842b..bcf66cb4202 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -256,6 +256,25 @@ type ManagedAgent struct { // Skills is an optional list of Foundry skill names attached to the agent. Skills []string `json:"skills,omitempty" yaml:"skills,omitempty"` + // Tools is an optional list of tool definitions attached to the agent. + // Entries are passed through verbatim to the Foundry managed-agent API, so + // author them using the API's snake_case tool schema. Supported types + // include (but are not limited to): function, code_interpreter, file_search, + // web_search, image_generation, mcp, azure_ai_search, azure_function, + // openapi, bing_grounding, bing_custom_search_preview, + // sharepoint_grounding_preview, memory_search_preview, fabric_iq_preview, + // fabric_dataagent_preview, work_iq_preview, a2a_preview, + // computer_use_preview, browser_automation_preview, toolbox_search_preview. + Tools []any `json:"tools,omitempty" yaml:"tools,omitempty"` + + // ToolChoice controls how/whether the model calls tools (e.g. "auto", + // "required", "none", or a specific tool object). Passed through verbatim. + ToolChoice any `json:"tool_choice,omitempty" yaml:"tool_choice,omitempty"` + + // StructuredInputs declares typed inputs the agent accepts per invocation. + // Passed through verbatim to the API. + StructuredInputs map[string]any `json:"structured_inputs,omitempty" yaml:"structured_inputs,omitempty"` + // Policies is an optional list of governance policies (e.g. RAI). Policies []Policy `json:"policies,omitempty" yaml:"policies,omitempty"` } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_tools_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_tools_test.go new file mode 100644 index 00000000000..cb10349d777 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_tools_test.go @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "encoding/json" + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/braydonk/yaml" +) + +// TestPromptAgentToolsPassthrough_BraydonkDecoder verifies that the tools, +// tool_choice, and structured_inputs authored in agent.yaml survive the +// braydonk/yaml decoder used by the deploy path (deployPromptAgent / +// loadPromptAgentDefinition) and are serialized verbatim into the create +// request body sent to the managed-agent API. +// +// This guards against decoder differences: the create-request mapping is unit +// tested with go.yaml.in/yaml/v3, but deploy reads the manifest with +// braydonk/yaml, which must produce JSON-marshalable maps/slices. +func TestPromptAgentToolsPassthrough_BraydonkDecoder(t *testing.T) { + yamlContent := []byte(` +kind: managed +name: kitchen-sink-agent +model: gpt-4o +instructions: You are a maximally capable assistant. +tool_choice: auto +structured_inputs: + user_context: + description: Extra context supplied per invocation + required: false +tools: + - type: function + name: calculate_sum + description: Adds two numbers + parameters: + type: object + properties: + a: { type: number } + b: { type: number } + required: [a, b] + strict: true + - type: mcp + server_label: github-mcp + server_url: https://api.githubcopilot.com/mcp + require_approval: always + - type: bing_grounding + bing_grounding: + search_configurations: + - project_connection_id: conn_bing_456 + - type: toolbox_search_preview +`) + + // Decode with the SAME library the deploy path uses. + var managed agent_yaml.ManagedAgent + if err := yaml.Unmarshal(yamlContent, &managed); err != nil { + t.Fatalf("braydonk unmarshal: %v", err) + } + if len(managed.Tools) != 4 { + t.Fatalf("tools: got %d, want 4", len(managed.Tools)) + } + + req, err := agent_yaml.CreateManagedAgentAPIRequest(managed, nil) + if err != nil { + t.Fatalf("CreateManagedAgentAPIRequest: %v", err) + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + body := string(data) + for _, want := range []string{ + `"tool_choice":"auto"`, + `"structured_inputs"`, + `"type":"function"`, + `"server_label":"github-mcp"`, + `"type":"bing_grounding"`, + `"type":"toolbox_search_preview"`, + } { + if !strings.Contains(body, want) { + t.Errorf("serialized request missing %s:\n%s", want, body) + } + } +} diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/.gitignore b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/.gitignore similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/.gitignore rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/.gitignore diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/agent.yaml b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/agent.yaml new file mode 100644 index 00000000000..f988ecb9b2f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/agent.yaml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ManagedAgent.yaml + +kind: managed +name: my-prompt-agent-0701-02-3 +model: gpt-4.1-mini +instructions: You are a helpful AI assistant. +tool_choice: auto +tools: + - type: function + name: calculate_sum + description: Adds two numbers + parameters: + type: object + properties: { a: { type: number }, b: { type: number } } + required: [a, b] + strict: true + - type: code_interpreter + container: auto + - type: mcp + server_label: github-mcp + server_url: https://api.githubcopilot.com/mcp + require_approval: always + - type: bing_grounding + bing_grounding: + search_configurations: + - project_connection_id: conn_bing_456 + - type: toolbox_search_preview \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/azure.yaml b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/azure.yaml new file mode 100644 index 00000000000..2399256b7b0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/azure.yaml @@ -0,0 +1,32 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +requiredVersions: + extensions: + azure.ai.agents: '>=0.1.0-preview' +name: ai-foundry-starter-basic +services: + my-prompt-agent-0701-02: + project: . + host: azure.ai.agent + language: "" + config: + deployments: + - model: + format: OpenAI + name: gpt-4.1-mini + version: "2025-04-14" + name: gpt-4.1-mini + sku: + capacity: 10 + name: GlobalStandard + promptAgent: + apiVersion: v1 + baseUrl: https://ai.azure.com/api + modelEndpoint: https://kchawla-wus2-0726.services.ai.azure.com + projectEndpoint: https://kchawla-wus2-0726.services.ai.azure.com/api/projects/kchawla-wus2-0726-project + resourceGroup: kchawla-rg-wus2 + subscriptionId: 2d385bf4-0756-4a76-aa95-28bf9ed3b625 + workspace: kchawla-wus2-0726-project +infra: + provider: bicep + path: ./infra diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/abbreviations.json b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/abbreviations.json similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/abbreviations.json rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/abbreviations.json diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/acr-role-assignment.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/acr-role-assignment.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/acr-role-assignment.bicep rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/acr-role-assignment.bicep diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/ai-project.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/ai-project.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/ai-project.bicep rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/ai-project.bicep diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/connection.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/connection.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/connection.bicep rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/connection.bicep diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/existing-ai-project.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/existing-ai-project.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/ai/existing-ai-project.bicep rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/existing-ai-project.bicep diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/host/acr.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/host/acr.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/host/acr.bicep rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/host/acr.bicep diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/applicationinsights-dashboard.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/applicationinsights-dashboard.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/applicationinsights-dashboard.bicep rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/applicationinsights-dashboard.bicep diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/applicationinsights.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/applicationinsights.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/applicationinsights.bicep rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/applicationinsights.bicep diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/loganalytics.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/loganalytics.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/monitor/loganalytics.bicep rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/loganalytics.bicep diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/azure_ai_search.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/azure_ai_search.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/azure_ai_search.bicep rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/azure_ai_search.bicep diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/bing_custom_grounding.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/bing_custom_grounding.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/bing_custom_grounding.bicep rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/bing_custom_grounding.bicep diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/bing_grounding.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/bing_grounding.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/search/bing_grounding.bicep rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/bing_grounding.bicep diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/storage/storage.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/storage/storage.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/core/storage/storage.bicep rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/storage/storage.bicep diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/main.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/main.bicep similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/main.bicep rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/main.bicep diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/main.parameters.json b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/main.parameters.json similarity index 100% rename from cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/infra/main.parameters.json rename to cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/main.parameters.json diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/azure.yaml b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/azure.yaml deleted file mode 100644 index b72682192eb..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-1031-0625/azure.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json -name: ai-foundry-starter-basic - -infra: - provider: bicep - path: ./infra - -requiredVersions: - extensions: - # the azd ai agent extension is required for this template - "azure.ai.agents": ">=0.1.0-preview" - diff --git a/cli/azd/extensions/azure.ai.agents/version.txt b/cli/azd/extensions/azure.ai.agents/version.txt index d76fe6c36e1..1b74d47f683 100644 --- a/cli/azd/extensions/azure.ai.agents/version.txt +++ b/cli/azd/extensions/azure.ai.agents/version.txt @@ -1 +1 @@ -0.1.42-preview +0.1.43-preview diff --git a/cli/azd/extensions/registry.json b/cli/azd/extensions/registry.json index fc0a162aa77..5c06f52d011 100644 --- a/cli/azd/extensions/registry.json +++ b/cli/azd/extensions/registry.json @@ -5091,6 +5091,88 @@ "version": "~0.0.1-preview" } ] + }, + { + "version": "0.1.43-preview", + "requiredAzdVersion": "\u003e1.25.2", + "capabilities": [ + "custom-commands", + "lifecycle-events", + "mcp-server", + "service-target-provider", + "metadata" + ], + "providers": [ + { + "name": "azure.ai.agent", + "type": "service-target", + "description": "Deploys agents to the Foundry Agent Service" + } + ], + "usage": "azd ai agent \u003ccommand\u003e [options]", + "examples": [ + { + "name": "init", + "description": "Initialize a new AI agent project.", + "usage": "azd ai agent init" + } + ], + "artifacts": { + "darwin/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "9c027b9f9d8f9cc6938b3ce2684595f336e044b6c715d811c37953839aec0622" + }, + "entryPoint": "azure-ai-agents-darwin-amd64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.43-preview/azure-ai-agents-darwin-amd64.zip" + }, + "darwin/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "70b34c0ded6f8d470d800489d644117d0efe5443c7636ac358b0e23ecfe543db" + }, + "entryPoint": "azure-ai-agents-darwin-arm64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.43-preview/azure-ai-agents-darwin-arm64.zip" + }, + "linux/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "ba947675afc30b72d95b478ea26e0113551a208fa5918f4081acfa233575b937" + }, + "entryPoint": "azure-ai-agents-linux-amd64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.43-preview/azure-ai-agents-linux-amd64.tar.gz" + }, + "linux/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "fa9a21b090790bdc24d08451246ce929d249a191f854ea95f8cc9e8391e95dd2" + }, + "entryPoint": "azure-ai-agents-linux-arm64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.43-preview/azure-ai-agents-linux-arm64.tar.gz" + }, + "windows/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "b6a45d69a2b27cdf6e3b763e0225a0e0dc5db2b1c1f51e467acc4e82d26ba0c3" + }, + "entryPoint": "azure-ai-agents-windows-amd64.exe", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.43-preview/azure-ai-agents-windows-amd64.zip" + }, + "windows/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "e81f572d7b36f0f6ca7ca4b95e9c2154569f46a2e096863af0fd802f0b5de1b8" + }, + "entryPoint": "azure-ai-agents-windows-arm64.exe", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.43-preview/azure-ai-agents-windows-arm64.zip" + } + }, + "dependencies": [ + { + "id": "azure.ai.inspector", + "version": "~0.0.1-preview" + } + ] } ] }, diff --git a/docs/specs/managed-harness-agents/generate_getting_started.py b/docs/specs/managed-harness-agents/generate_getting_started.py new file mode 100644 index 00000000000..601ca9f8311 --- /dev/null +++ b/docs/specs/managed-harness-agents/generate_getting_started.py @@ -0,0 +1,239 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# +# Generates the "Managed (Harness) Agents — Getting Started" Word document. +# PM-oriented framing for early-access customers. +# +# Run: +# python generate_getting_started.py +# +# Output: managed-agents-getting-started.docx in this directory. + +from __future__ import annotations + +import os + +from docx import Document +from docx.enum.style import WD_STYLE_TYPE +from docx.enum.table import WD_ALIGN_VERTICAL +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.oxml import OxmlElement +from docx.shared import Pt, RGBColor, Inches + + +def _ensure_code_style(doc: Document) -> None: + if "Code Block" in [s.name for s in doc.styles]: + return + style = doc.styles.add_style("Code Block", WD_STYLE_TYPE.PARAGRAPH) + style.font.name = "Consolas" + style.font.size = Pt(9) + style.font.color.rgb = RGBColor(0x1F, 0x1F, 0x1F) + pf = style.paragraph_format + pf.space_before = Pt(4) + pf.space_after = Pt(8) + pf.left_indent = Inches(0.25) + + +def _shade(p, fill="F2F2F2") -> None: + ppr = p._p.get_or_add_pPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), fill) + ppr.append(shd) + + +def code(doc: Document, text: str) -> None: + p = doc.add_paragraph(style="Code Block") + _shade(p) + p.add_run(text.rstrip("\n")) + + +def inline(p, text: str) -> None: + run = p.add_run(text) + run.font.name = "Consolas" + run.font.size = Pt(10) + + +def para(doc: Document, text: str) -> None: + p = doc.add_paragraph() + rem = text + while rem: + i = rem.find("[[c:") + if i < 0: + p.add_run(rem) + break + p.add_run(rem[:i]) + e = rem.find("]]", i) + inline(p, rem[i + 4 : e]) + rem = rem[e + 2 :] + + +def bullets(doc: Document, items: list[str]) -> None: + for it in items: + p = doc.add_paragraph(style="List Bullet") + rem = it + while rem: + i = rem.find("[[c:") + if i < 0: + p.add_run(rem) + break + p.add_run(rem[:i]) + e = rem.find("]]", i) + inline(p, rem[i + 4 : e]) + rem = rem[e + 2 :] + + +def h1(doc, t): doc.add_heading(t, level=1) +def h2(doc, t): doc.add_heading(t, level=2) + + +def table(doc: Document, header: list[str], rows: list[list[str]]) -> None: + t = doc.add_table(rows=1 + len(rows), cols=len(header)) + t.style = "Light Grid Accent 1" + for i, h in enumerate(header): + t.rows[0].cells[i].paragraphs[0].add_run(h).bold = True + for r, row in enumerate(rows, 1): + for c, v in enumerate(row): + cell = t.rows[r].cells[c] + p = cell.paragraphs[0] + rem = v + while rem: + i = rem.find("[[c:") + if i < 0: + p.add_run(rem) + break + p.add_run(rem[:i]) + e = rem.find("]]", i) + inline(p, rem[i + 4 : e]) + rem = rem[e + 2 :] + cell.vertical_alignment = WD_ALIGN_VERTICAL.TOP + + +def build() -> Document: + doc = Document() + doc.styles["Normal"].font.name = "Calibri" + doc.styles["Normal"].font.size = Pt(11) + _ensure_code_style(doc) + + title = doc.add_paragraph() + r = title.add_run("Managed (Harness) Agents — Getting Started") + r.bold = True + r.font.size = Pt(24) + sub = doc.add_paragraph() + s = sub.add_run("Early-access guide · CLI and SDK · Microsoft Foundry") + s.italic = True + s.font.size = Pt(12) + s.font.color.rgb = RGBColor(0x4A, 0x4A, 0x4A) + meta = doc.add_paragraph() + meta.add_run("Status: Preview Audience: early-access customers Updated: June 2026").italic = True + doc.add_paragraph() + + h1(doc, "Why managed agents") + para(doc, + "A managed agent lets you ship a working AI agent by declaring just two things: a model and " + "instructions. Microsoft Foundry provisions and runs the Brain+Hand sandbox for you — there is no " + "container to build, no service code to host, and no infrastructure to manage. You go from idea to a " + "deployed, callable agent in minutes.") + bullets(doc, [ + "Time-to-first-agent measured in minutes, not days.", + "No Dockerfile, no servers, no scaling decisions — the platform owns the runtime.", + "One agent, two front doors: create with the CLI or the SDK; both target the same Foundry project.", + "Standard OpenAI-shape Responses API for invocation, so existing tooling fits.", + ]) + + h1(doc, "What you'll need") + bullets(doc, [ + "An Azure subscription and a Foundry project (a [[c:CognitiveServices/accounts/projects]] resource).", + "A model deployment in that project (e.g. [[c:gpt-4.1-mini]]).", + "Sign-in via [[c:azd auth login]] / [[c:az login]].", + "Project endpoint [[c:AZURE_AI_PROJECT_ENDPOINT]] = https://.services.ai.azure.com/api/projects/", + "Model name [[c:AZURE_AI_MODEL_DEPLOYMENT_NAME]] = e.g. gpt-4.1-mini", + ]) + + h1(doc, "Option A — azd CLI (fastest path)") + h2(doc, "1. Install") + code(doc, + "winget install microsoft.azd\n" + "azd extension install microsoft.azd.extensions\n" + "azd extension source add --name MHA-dev --type url " + "--location https://raw.githubusercontent.com/kshitij-microsoft/azure-dev/" + "refs/heads/kchawla/azd-managed-harness/cli/azd/extensions/registry.json\n" + "azd extension install azure.ai.agents --source MHA-dev\n" + "azd auth login") + h2(doc, "2. Create") + para(doc, "Choose Prompt agent, pick your subscription and Foundry project, choose a model, and name it.") + code(doc, "azd ai agent init") + h2(doc, "3. Deploy and use") + code(doc, "azd up\nazd ai agent list\nazd ai agent show\nazd ai agent invoke \"hello, what is your name?\"") + para(doc, "`azd down` removes the agent with the project resources.") + + h1(doc, "Option B — Python SDK") + h2(doc, "1. Install") + code(doc, + "pip install azure-ai-projects==2.3.0a20260625001 " + "--extra-index-url https://pkgs.dev.azure.com/azure-sdk/public/_packaging/" + "azure-sdk-for-python/pypi/simple\n" + "pip install azure-identity python-dotenv") + h2(doc, "2. Create a managed agent") + code(doc, + "from azure.identity import DefaultAzureCredential\n" + "from azure.ai.projects import AIProjectClient\n" + "from azure.ai.projects.models import PromptAgentDefinition, AgentHarness\n\n" + "client = AIProjectClient(endpoint=endpoint, credential=DefaultAzureCredential(), allow_preview=True)\n\n" + "client.agents.create_version(\n" + " agent_name=\"my-managed-agent\",\n" + " definition=PromptAgentDefinition(\n" + " model=model_name,\n" + " instructions=\"You are a helpful assistant.\",\n" + " harness=AgentHarness.GHCP,\n" + " ),\n" + ")") + h2(doc, "3. Invoke") + code(doc, + "openai_client = client.get_openai_client()\n" + "response = openai_client.responses.create(\n" + " input=[{\"role\": \"user\", \"content\": \"Generate python to print the OS and run it.\"}],\n" + " store=False,\n" + " extra_body={\"agent_reference\": {\"name\": \"my-managed-agent\", \"version\": \"1\", " + "\"type\": \"agent_reference\"}},\n" + ")") + + h1(doc, "What a response looks like") + para(doc, "Invocations stream Server-Sent Events from the project data-plane. The Brain plans the turn and " + "the Hand sandbox runs any tools/code; only [[c:output_text.delta]] events carry visible text.") + code(doc, + "POST .../api/projects//openai/v1/responses\n" + "x-agent-session-id: ses_...\n\n" + "event: response.created\n" + "event: response.output_text.delta\n" + "event: response.completed") + + h1(doc, "CLI vs SDK at a glance") + table(doc, + ["Task", "CLI", "SDK"], + [ + ["Create", "azd ai agent init + azd up", "create_version(..., harness=GHCP)"], + ["Invoke", "azd ai agent invoke", "responses.create(agent_reference)"], + ["List / show", "azd ai agent list / show", "agents.list / get_version"], + ["Tear down", "azd down", "delete on the project"], + ]) + + h1(doc, "Recommended next steps") + bullets(doc, [ + "Pick the path that matches the customer: CLI for hands-on demos, SDK for app integration.", + "Both write to the same Foundry project — an agent made via SDK shows up in the CLI and vice versa.", + "Share feedback on time-to-first-agent and any blocking errors during the bug bash.", + ]) + return doc + + +def main() -> None: + out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "managed-agents-getting-started.docx") + build().save(out) + print(f"Wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/docs/specs/managed-harness-agents/generate_spec.py b/docs/specs/managed-harness-agents/generate_spec.py new file mode 100644 index 00000000000..10a70f8aa2c --- /dev/null +++ b/docs/specs/managed-harness-agents/generate_spec.py @@ -0,0 +1,863 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# +# Generates the "Managed (Harness) Agents in azd" Word document spec. +# +# Run: +# python generate_spec.py +# +# Output: spec.docx in the same directory. + +from __future__ import annotations + +import os +from dataclasses import dataclass + +from docx import Document +from docx.enum.style import WD_STYLE_TYPE +from docx.enum.table import WD_ALIGN_VERTICAL +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.oxml import OxmlElement +from docx.shared import Pt, RGBColor, Inches + + +# ----------------------------- styling helpers ----------------------------- + + +def _ensure_code_style(doc: Document) -> None: + """Create a "Code Block" character style (Consolas, dark gray).""" + styles = doc.styles + if "Code Block" in [s.name for s in styles]: + return + style = styles.add_style("Code Block", WD_STYLE_TYPE.PARAGRAPH) + font = style.font + font.name = "Consolas" + font.size = Pt(9) + font.color.rgb = RGBColor(0x1F, 0x1F, 0x1F) + pf = style.paragraph_format + pf.space_before = Pt(4) + pf.space_after = Pt(8) + pf.left_indent = Inches(0.25) + + +def _ensure_inline_code_style(doc: Document) -> None: + styles = doc.styles + if "InlineCode" in [s.name for s in styles]: + return + style = styles.add_style("InlineCode", WD_STYLE_TYPE.CHARACTER) + style.font.name = "Consolas" + style.font.size = Pt(10) + + +def _shade_paragraph(paragraph, fill_hex: str = "F2F2F2") -> None: + """Apply a background fill to a paragraph (for code blocks).""" + p_pr = paragraph._p.get_or_add_pPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), fill_hex) + p_pr.append(shd) + + +def add_code_block(doc: Document, code: str, language: str | None = None) -> None: + para = doc.add_paragraph(style="Code Block") + _shade_paragraph(para) + para.add_run(code.rstrip("\n")) + + +def add_inline_code(paragraph, text: str) -> None: + run = paragraph.add_run(text) + run.font.name = "Consolas" + run.font.size = Pt(10) + + +def add_para(doc: Document, text: str) -> None: + """Add a normal paragraph. Use [[code:foo]] to render inline code spans.""" + para = doc.add_paragraph() + remaining = text + while remaining: + idx = remaining.find("[[code:") + if idx == -1: + para.add_run(remaining) + break + para.add_run(remaining[:idx]) + end = remaining.find("]]", idx) + if end == -1: + para.add_run(remaining[idx:]) + break + add_inline_code(para, remaining[idx + 7 : end]) + remaining = remaining[end + 2 :] + + +def add_bullets(doc: Document, items: list[str]) -> None: + for item in items: + para = doc.add_paragraph(style="List Bullet") + remaining = item + while remaining: + idx = remaining.find("[[code:") + if idx == -1: + para.add_run(remaining) + break + para.add_run(remaining[:idx]) + end = remaining.find("]]", idx) + if end == -1: + para.add_run(remaining[idx:]) + break + add_inline_code(para, remaining[idx + 7 : end]) + remaining = remaining[end + 2 :] + + +def add_h1(doc: Document, text: str) -> None: + para = doc.add_heading(text, level=1) + para.paragraph_format.space_before = Pt(18) + + +def add_h2(doc: Document, text: str) -> None: + doc.add_heading(text, level=2) + + +def add_h3(doc: Document, text: str) -> None: + doc.add_heading(text, level=3) + + +def add_table(doc: Document, header: list[str], rows: list[list[str]]) -> None: + table = doc.add_table(rows=1 + len(rows), cols=len(header)) + table.style = "Light Grid Accent 1" + hdr_cells = table.rows[0].cells + for i, h in enumerate(header): + hdr_cells[i].text = "" + run = hdr_cells[i].paragraphs[0].add_run(h) + run.bold = True + for r, row in enumerate(rows, start=1): + for c, val in enumerate(row): + cell = table.rows[r].cells[c] + cell.text = "" + para = cell.paragraphs[0] + # Honor inline code in cells. + remaining = val + while remaining: + idx = remaining.find("[[code:") + if idx == -1: + para.add_run(remaining) + break + para.add_run(remaining[:idx]) + end = remaining.find("]]", idx) + if end == -1: + para.add_run(remaining[idx:]) + break + add_inline_code(para, remaining[idx + 7 : end]) + remaining = remaining[end + 2 :] + cell.vertical_alignment = WD_ALIGN_VERTICAL.TOP + + +# ----------------------------- content builders --------------------------- + + +def build_title(doc: Document) -> None: + title = doc.add_paragraph() + title.alignment = WD_ALIGN_PARAGRAPH.LEFT + run = title.add_run("Managed (Harness) Agents in azd") + run.bold = True + run.font.size = Pt(26) + + subtitle = doc.add_paragraph() + sub = subtitle.add_run( + "Design spec for the `managed` agent kind in the `azd ai agent` extension" + ) + sub.italic = True + sub.font.size = Pt(12) + sub.font.color.rgb = RGBColor(0x4A, 0x4A, 0x4A) + + meta = doc.add_paragraph() + meta.add_run("Status: Implemented (preview) Owner: azd ai agent team Audience: azd contributors").italic = True + + doc.add_paragraph() # spacer + + +def build_overview(doc: Document) -> None: + add_h1(doc, "Overview") + add_para( + doc, + "The Azure AI Foundry agent platform exposes three first-class agent kinds today: " + "[[code:hosted]] (bring-your-own container/code), [[code:workflow]] (multi-step orchestration), " + "and a new [[code:managed]] kind backed by the Prompt Execution Service (PES) Brain+Hand harness. " + "Managed agents are the simplest shape: the customer declares a model deployment and " + "system instructions; the platform provisions the runtime, executes turns, and persists " + "state. There is no container to build, no Dockerfile, and no service code on the customer side.", + ) + add_para( + doc, + "This spec describes how the [[code:azure.ai.agents]] azd extension implements first-class " + "support for managed agents end-to-end: YAML schema, API wire types, ARM-shaped HTTP client, " + "init scaffolding flow, delete dispatch, and local-development affordances against the " + "[[code:managed-harness]] vienna backend.", + ) + + +def build_goals(doc: Document) -> None: + add_h1(doc, "Goals and Non-Goals") + add_h3(doc, "Goals") + add_bullets( + doc, + [ + "Add a [[code:managed]] discriminator to [[code:AgentKind]] and an accompanying " + "[[code:ManagedAgent]] YAML type that can round-trip through the existing parser.", + "Map the YAML definition to the wire shape ([[code:ManagedAgentDefinition]] + " + "[[code:ManagedEnvironment]] + [[code:ManagedPackages]]) accepted by the v2.0 " + "managed-agents controller.", + "Add an ARM-rooted HTTP client ([[code:ManagedAgentClient]]) covering the lifecycle " + "(create / get / update / delete / list) and the Responses subtree " + "(create / get / cancel / delete).", + "Wire the init flow to ask which agent kind to create as the very first interactive " + "step, and add a [[code:runInitManaged]] path that scaffolds the minimum surface " + "(agent.yaml + azure.yaml service entry) with no Docker, no Language, no src/.", + "Wire the delete flow to detect managed agents via the YAML discriminator and route " + "deletion through [[code:ManagedAgentClient.DeleteAgent]] rather than the hosted path.", + "Allow developer machines to target a local [[code:managed-harness]] backend " + "without an Azure login (env-var override + credential-skip for localhost).", + "Keep all existing hosted-agent code paths byte-identical when [[code:kind]] is not " + "[[code:managed]].", + ], + ) + + add_h3(doc, "Non-Goals (this milestone)") + add_bullets( + doc, + [ + "[[code:azd ai agent show]] / [[code:list]] / [[code:invoke]] wiring for managed agents " + "(designed but deferred — see Open Questions).", + "Hosted versioning semantics for managed agents — the backend does not expose a per-version " + "delete on the v2.0 surface, so [[code:--version]] is rejected with a typed validation error.", + "Surfacing every advanced [[code:ManagedAgentDefinition]] field " + "([[code:structured_inputs]], [[code:files]], full [[code:environment]] block) " + "through YAML — only [[code:model]], [[code:instructions]], [[code:skills]], " + "and [[code:policies]] are exposed today.", + "Automatic ARM workspace discovery from a Foundry project endpoint — callers must " + "set [[code:AZD_MANAGED_AGENT_SUBSCRIPTION_ID]] / [[code:_RESOURCE_GROUP]] / " + "[[code:_WORKSPACE]] explicitly.", + "Schema publication — the [[code:agent.yaml]] schema annotation points at " + "[[code:microsoft/AgentSchema]] and assumes that repo will pick up a " + "[[code:ManagedAgent.yaml]] sibling alongside the existing kinds.", + ], + ) + + +def build_user_stories(doc: Document) -> None: + add_h1(doc, "User Stories") + add_bullets( + doc, + [ + "As a developer I run [[code:azd ai agent init]] in an empty folder, choose " + "\u201cManaged agent\u201d, answer three prompts (name / model / instructions), " + "and end up with an [[code:agent.yaml]] and an [[code:azure.yaml]] service entry " + "ready for [[code:azd deploy]].", + "As a developer I set [[code:FOUNDRY_PROJECT_ENDPOINT]] in my azd environment, run " + "[[code:azd deploy]], and the managed agent is created on Foundry without azd " + "building any container or pushing any code.", + "As a developer I run [[code:azd ai agent delete --service ]] and the " + "extension detects [[code:kind: managed]] in the service's [[code:agent.yaml]] and " + "deletes via the managed lifecycle endpoint instead of the hosted one.", + "As a Foundry platform contributor I run a local [[code:managed-harness]] vienna " + "backend on [[code:http://localhost:5000]], set [[code:AZD_FOUNDRY_ENDPOINT_OVERRIDE=1]] " + "and [[code:AZD_MANAGED_AGENT_BASE_URL=http://localhost:5000]], and exercise the full " + "azd-managed-agent surface against my dev box without an Azure login.", + ], + ) + + +def build_architecture(doc: Document) -> None: + add_h1(doc, "Architecture") + add_para( + doc, + "Managed support is layered onto the existing extension along the same seams as the other " + "agent kinds. The discriminator is [[code:agent.yaml \u2192 kind]]; everything downstream " + "switches on that value.", + ) + add_code_block( + doc, + """\ +azure.ai.agents extension +\u251c\u2500 internal/pkg/agents/agent_yaml/ +\u2502 \u251c\u2500 yaml.go \u2190 ManagedAgent struct + AgentKindManaged constant +\u2502 \u251c\u2500 parse.go \u2190 switch on kind \u2192 unmarshal to ManagedAgent +\u2502 \u251c\u2500 map.go \u2190 CreateManagedAgentAPIRequest(...) \u2192 wire type +\u2502 \u2514\u2500 managed_test.go \u2190 round-trip / validate / dispatcher coverage +\u2502 +\u251c\u2500 internal/pkg/agents/agent_api/ +\u2502 \u251c\u2500 models.go \u2190 ManagedAgentDefinition / ManagedEnvironment / ManagedPackages +\u2502 \u251c\u2500 managed_operations.go\u2190 ManagedAgentClient (lifecycle + responses) + BuildWorkspaceRoutePrefix +\u2502 \u2514\u2500 managed_operations_test.go +\u2502 +\u2514\u2500 internal/cmd/ + \u251c\u2500 init.go \u2190 prompts kind first; routes to runInitManaged when "managed" + \u251c\u2500 init_from_templates_helpers.go \u2190 promptAgentKind() Select + \u251c\u2500 init_managed.go \u2190 scaffolds agent.yaml + adds azure.yaml service entry (no Docker, no src/) + \u251c\u2500 managed_dispatch.go \u2190 isManagedAgentYAML / newManagedAgentClientFromEnv / localhost detection + \u251c\u2500 delete.go \u2190 detects kind \u2192 runManagedDelete via ManagedAgentClient + \u2514\u2500 project_endpoint.go \u2190 AZD_FOUNDRY_ENDPOINT_OVERRIDE bypass for local http:// targets +""", + ) + add_para( + doc, + "The split between [[code:agent_yaml]] and [[code:agent_api]] mirrors the hosted/workflow " + "kinds: [[code:agent_yaml]] is the customer-authored shape, [[code:agent_api]] is the " + "wire shape sent to Foundry. [[code:agent_yaml/map.go]] is the only crossover.", + ) + + +def build_yaml_schema(doc: Document) -> None: + add_h1(doc, "YAML Schema") + add_para( + doc, + "A managed [[code:agent.yaml]] is small by design \u2014 the platform owns the runtime, " + "so the customer-authored shape is just [[code:kind]], [[code:name]], [[code:model]], " + "[[code:instructions]], optional [[code:skills]], and optional [[code:policies]].", + ) + + add_h3(doc, "Minimum example") + add_code_block( + doc, + """\ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ManagedAgent.yaml + +kind: managed +name: customer-support-bot +model: gpt-4.1-mini +instructions: | + You are a helpful customer-support agent. + Always reply in the user's language and cite a knowledge-base + article when you give a factual answer. +""", + ) + + add_h3(doc, "Full example (skills + RAI policy)") + add_code_block( + doc, + """\ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ManagedAgent.yaml + +kind: managed +name: research-assistant +displayName: Research Assistant +description: Summarizes long-form web content with citations. +model: gpt-4.1-mini +instructions: | + You are a research assistant. Cite every source you use. +skills: + - foundry.tools.web_search + - foundry.tools.code_interpreter +policies: + - type: rai_policy + rai_policy_name: /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//raiPolicies/ +""", + ) + + add_h3(doc, "Field reference") + add_table( + doc, + ["Field", "Required", "Type", "Notes"], + [ + ["[[code:kind]]", "yes", "string", "Must be the literal [[code:managed]]."], + ["[[code:name]]", "yes", "string", "Foundry agent identity. Also used as the folder name when scaffolding into a non-empty cwd."], + ["[[code:displayName]]", "no", "string", "Optional human-readable label."], + ["[[code:description]]", "no", "string", "Optional description."], + ["[[code:metadata]]", "no", "map", "Free-form key/value tags. [[code:authors]] is special-cased into a comma-separated string by the mapper."], + ["[[code:model]]", "yes", "string", "Model deployment name (e.g. [[code:gpt-4.1-mini]]). Validated non-empty by [[code:CreateManagedAgentAPIRequest]]."], + ["[[code:instructions]]", "yes", "string", "System/developer message inserted into the model context. Validated non-empty by [[code:CreateManagedAgentAPIRequest]]."], + ["[[code:skills]]", "no", "string[]", "Optional list of Foundry skill identifiers attached to the agent."], + ["[[code:policies]]", "no", "Policy[]", "Optional governance policies. Today only [[code:type: rai_policy]] with an ARM-id-shaped [[code:rai_policy_name]] is supported."], + ], + ) + + add_h3(doc, "Discriminator routing") + add_para( + doc, + "[[code:agent_yaml/parse.go]] switches on the [[code:kind]] field and unmarshals into " + "the matching type. The managed branch is symmetric with [[code:hosted]] and [[code:workflow]]:", + ) + add_code_block( + doc, + """\ +switch agentDef.Kind { +case AgentKindHosted: + // ... ContainerAgent +case AgentKindWorkflow: + // ... Workflow +case AgentKindManaged: + var agent ManagedAgent + if err := yaml.Unmarshal(data, &agent); err != nil { + return nil, fmt.Errorf("failed to unmarshal to ManagedAgent: %w", err) + } + return agent, nil +} +return nil, fmt.Errorf("unrecognized agent kind: %s", agentDef.Kind) +""", + ) + + +def build_wire_contract(doc: Document) -> None: + add_h1(doc, "API Wire Contract") + add_para( + doc, + "The wire shape lives in [[code:internal/pkg/agents/agent_api/models.go]]. It is the " + "JSON body POSTed under the standard [[code:CreateAgentRequest]] envelope, with " + "[[code:Definition]] set to a [[code:ManagedAgentDefinition]].", + ) + + add_h3(doc, "[[code:ManagedAgentDefinition]]") + add_code_block( + doc, + """\ +type ManagedAgentDefinition struct { + AgentDefinition + Model string `json:"model"` + Instructions string `json:"instructions,omitempty"` + Tools []any `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Skills []string `json:"skills,omitempty"` + StructuredInputs map[string]any `json:"structured_inputs,omitempty"` + Environment *ManagedEnvironment `json:"environment,omitempty"` + Files map[string]string `json:"files,omitempty"` +} +""", + ) + + add_h3(doc, "[[code:ManagedEnvironment]] / [[code:ManagedPackages]]") + add_code_block( + doc, + """\ +type ManagedPackages struct { + Pip []string `json:"pip,omitempty"` + Apt []string `json:"apt,omitempty"` +} + +type ManagedEnvironment struct { + BaseImage *string `json:"base_image,omitempty"` + Image *string `json:"image,omitempty"` + Packages *ManagedPackages `json:"packages,omitempty"` + CPU *string `json:"cpu,omitempty"` + Memory *string `json:"memory,omitempty"` + EgressPolicy *string `json:"egress_policy,omitempty"` + EnvironmentVariables map[string]string `json:"environment_variables,omitempty"` +} +""", + ) + + add_h3(doc, "Mapping rules ([[code:CreateManagedAgentAPIRequest]])") + add_bullets( + doc, + [ + "[[code:model]] and [[code:instructions]] are validated non-empty; otherwise the call " + "returns [[code:fmt.Errorf]] (\u201cmanaged agent requires a non-empty model/instructions\u201d).", + "[[code:policies]] are run through [[code:mapRaiConfig]] (the same helper used by hosted " + "agents) to produce [[code:AgentDefinition.RaiConfig]] on the wire.", + "[[code:skills]] are cloned ([[code:append([]string(nil), ...]]) so the request does not " + "alias the customer-supplied slice.", + "When a non-nil [[code:AgentBuildConfig]] carries [[code:EnvironmentVariables]], they are " + "copied (via [[code:maps.Clone]]) into [[code:Environment.EnvironmentVariables]] so the " + "Hand sandbox can read them.", + "No [[code:image]], [[code:cpu]], [[code:memory]], or [[code:endpoint]] fields are set " + "from the YAML \u2014 these belong to the hosted/container shape and are not part of the " + "managed customer-authored surface today.", + ], + ) + + +def build_url_surface(doc: Document) -> None: + add_h1(doc, "URL Surface and [[code:ManagedAgentClient]]") + add_para( + doc, + "All managed operations are rooted at an ARM-shaped workspace resource. The client takes a " + "[[code:BaseURL]] (origin) and a [[code:RoutePrefix]] (everything between the origin and " + "[[code:/agents]]) so the same client targets production, an alternate cloud, or a local " + "[[code:managed-harness]] backend without rewiring URL assembly:", + ) + add_code_block( + doc, + """\ +{baseURL}{routePrefix}/agents +{baseURL}{routePrefix}/agents/{name} +{baseURL}{routePrefix}/agents/{name}/openai/responses +{baseURL}{routePrefix}/agents/{name}/openai/responses/{responseId} +{baseURL}{routePrefix}/agents/{name}/openai/responses/{responseId}/cancel +""", + ) + + add_h3(doc, "Production route prefix") + add_para( + doc, + "Built by [[code:BuildWorkspaceRoutePrefix(sub, rg, ws)]]. Each segment is " + "[[code:url.PathEscape]]'d:", + ) + add_code_block( + doc, + """\ +/agents/v2.0/subscriptions//resourceGroups//providers/Microsoft.MachineLearningServices/workspaces/ +""", + ) + + add_h3(doc, "Operations") + add_table( + doc, + ["Method", "URL suffix (after [[code:{baseURL}{routePrefix}/agents]])", "Accepted statuses", "Notes"], + [ + ["[[code:CreateAgent]]", "", "200, 201", "POST. Body is [[code:CreateAgentRequest]] (envelope) with a [[code:ManagedAgentDefinition]]."], + ["[[code:GetAgent]]", "/{name}", "200", "Path-escaped agent name."], + ["[[code:UpdateAgent]]", "/{name}", "200", "POST (intentional; the controller treats POST-to-name as replace)."], + ["[[code:DeleteAgent]]", "/{name}?force=", "200, 204", "Accepts 204 because vienna returns it in some configs; synthesizes [[code:{Deleted: true, Name: name}]] when the body is empty."], + ["[[code:ListAgents]]", "?kind/limit/after/before/order", "200", "All filters optional."], + ["[[code:CreateResponse]]", "/{name}/openai/responses", "200, 201, 202", "Body passes through verbatim \u2014 callers serialize the OpenAI Responses shape themselves; caller-supplied headers are forwarded."], + ["[[code:GetResponse]]", "/{name}/openai/responses/{responseId}", "200", "Raw body + cloned response headers returned."], + ["[[code:CancelResponse]]", "/{name}/openai/responses/{responseId}/cancel", "200, 202", "POST."], + ["[[code:DeleteResponse]]", "/{name}/openai/responses/{responseId}", "200, 204", ""], + ], + ) + + add_h3(doc, "Construction") + add_code_block( + doc, + """\ +client, err := agent_api.NewManagedAgentClient(agent_api.ManagedAgentClientOptions{ + BaseURL: "https://management.azure.com", + RoutePrefix: prefix, // from BuildWorkspaceRoutePrefix(sub, rg, ws) + Credential: cred, // azcore.TokenCredential (nil for unauthenticated localhost) + Scopes: nil, // defaults to {"https://ai.azure.com/.default"} +}) +""", + ) + + add_h3(doc, "Pipeline policies") + add_bullets( + doc, + [ + "[[code:NewMsCorrelationPolicy]] \u2014 attaches [[code:x-ms-correlation-request-id]].", + "[[code:NewUserAgentPolicy]] \u2014 [[code:azd-ext-azure-ai-agents/]].", + "[[code:NewBearerTokenPolicy]] (when [[code:Credential]] is non-nil) using scopes " + "[[code:https://ai.azure.com/.default]] by default. The policy is prepended so it runs " + "before correlation/user-agent.", + "Logging is configured with [[code:IncludeBody=true]] and an allowlist for " + "[[code:X-Ms-Correlation-Request-Id]] / [[code:X-Request-Id]].", + ], + ) + + add_h3(doc, "API version") + add_para( + doc, + "Sent on every request via the [[code:api-version]] query param. The default is " + "[[code:DefaultManagedAgentAPIVersion = \"2025-08-01-preview\"]]. Defining it as an " + "exported constant keeps test wire assertions and command call sites in lockstep when " + "the backend rolls forward.", + ) + + +def build_cli_surface(doc: Document) -> None: + add_h1(doc, "CLI Surface") + + add_h3(doc, "[[code:azd ai agent init]] \u2014 kind selection") + add_para( + doc, + "Before any hosted-specific detection runs (manifest discovery, [[code:--src]] handling, " + "deploy-mode/runtime/entry-point flags), the [[code:init]] command asks the user which " + "kind to scaffold. The prompt is suppressed when any \u201chosted signal\u201d is present " + "on the command line so existing CI scripts stay on the hosted path:", + ) + add_code_block( + doc, + """\ +hostedSignalsPresent := userProvidedManifest || + flags.src != "" || + flags.deployMode != "" || + flags.runtime != "" || + flags.entryPoint != "" +if !hostedSignalsPresent { + kindChoice, kindErr := promptAgentKind(ctx, azdClient, flags.noPrompt) + if kindErr != nil { + return kindErr + } + if kindChoice == AgentKindChoiceManaged { + return runInitManaged(ctx, flags, azdClient) + } +} +""", + ) + add_para( + doc, + "[[code:promptAgentKind]] returns [[code:AgentKindChoiceHosted]] in [[code:--no-prompt]] " + "mode to preserve today's behaviour for callers that do not yet know about the new kind.", + ) + + add_h3(doc, "[[code:runInitManaged]] \u2014 scaffolding flow") + add_bullets( + doc, + [ + "Prompt for [[code:agent name]] (default [[code:my-managed-agent]]; " + "[[code:--agent-name]] required in [[code:--no-prompt]] mode).", + "Prompt for [[code:model deployment]] (default [[code:gpt-4.1-mini]]; " + "[[code:--model]] required in [[code:--no-prompt]] mode).", + "Prompt for [[code:system instructions]] (default placeholder; in [[code:--no-prompt]] " + "mode a self-documenting stub is written so the customer can edit before deploying).", + "Resolve target directory: write at the cwd when empty, otherwise create a sanitized " + "subfolder named after the agent. Refuses to clobber a non-empty existing subfolder.", + "Write [[code:agent.yaml]] with the [[code:yaml-language-server]] schema annotation " + "pointing at the [[code:ManagedAgent.yaml]] schema.", + "Add an [[code:azure.yaml]] service entry via [[code:azdClient.Project().AddService]] " + "with [[code:Host: azure.ai.agent]] and no [[code:Language]] / no [[code:Docker]]. " + "If no [[code:azure.yaml]] exists, return a typed dependency error pointing the user " + "at [[code:azd init]] \u2014 we intentionally do not scaffold a project here.", + "Print a concise summary and a copy-paste-able next-steps block " + "([[code:azd env set FOUNDRY_PROJECT_ENDPOINT \u2026]] \u2192 [[code:azd deploy]]).", + ], + ) + add_para( + doc, + "Critically, [[code:runInitManaged]] does NOT call [[code:ensureProject]] / clone a " + "[[code:azd-ai-starter-basic]] template / scaffold any Bicep. Managed agents assume the " + "Foundry project endpoint already exists \u2014 forcing the user through hosted-shaped " + "infra scaffolding would be misleading.", + ) + + add_h3(doc, "[[code:azd ai agent delete]] \u2014 dispatch") + add_para( + doc, + "The delete command inspects the service's [[code:agent.yaml]] via " + "[[code:isManagedAgentYAML]]. If the discriminator is [[code:managed]], it routes to " + "[[code:DeleteAction.runManagedDelete]]:", + ) + add_bullets( + doc, + [ + "[[code:--version]] is rejected up-front with a typed validation error " + "([[code:CodeInvalidParameter]]) \u2014 managed agents do not expose per-version delete " + "on the v2.0 surface.", + "Constructs a [[code:ManagedAgentClient]] via [[code:newManagedAgentClientFromEnv]].", + "Calls [[code:DeleteAgent(ctx, name, DefaultManagedAgentAPIVersion, force)]] and " + "passes [[code:--force]] through to the [[code:force]] query parameter.", + "On success, best-effort cleans up the matching env-var keys and session state to " + "stay at parity with the hosted delete path.", + "Honors [[code:--output json]] by emitting [[code:DeleteAgentResponse]] directly; " + "the default human-readable output is a one-liner ([[code:Managed agent \"\" deleted.]]).", + ], + ) + + +def build_dispatch_and_envvars(doc: Document) -> None: + add_h1(doc, "Lifecycle Dispatch and Environment Variables") + add_para( + doc, + "All command-side managed plumbing lives in [[code:internal/cmd/managed_dispatch.go]]. " + "It provides three helpers plus a small block of env-var constants:", + ) + + add_h3(doc, "[[code:isManagedAgentYAML(filePath string) (bool, error)]]") + add_para( + doc, + "Reads the file and unmarshals only the [[code:kind]] field via a probe struct. A missing " + "file returns [[code:(false, nil)]] so callers can treat \u201cno agent.yaml present\u201d " + "as \u201cnot a managed agent\u201d rather than an error. A malformed file returns a wrapped " + "[[code:yaml.Unmarshal]] error so the surrounding command can surface it.", + ) + + add_h3(doc, "[[code:newManagedAgentClientFromEnv(ctx) (*ManagedAgentClient, error)]]") + add_bullets( + doc, + [ + "Reads [[code:AZD_MANAGED_AGENT_SUBSCRIPTION_ID]] / [[code:AZD_MANAGED_AGENT_RESOURCE_GROUP]] / " + "[[code:AZD_MANAGED_AGENT_WORKSPACE]] from the process environment.", + "When any of the three is missing or empty, returns a typed validation error " + "([[code:CodeInvalidParameter]]) whose suggestion lists exactly which env vars to set.", + "Builds the route prefix via [[code:BuildWorkspaceRoutePrefix]] (so the same escaping " + "and validation rules apply as the unit tests in [[code:managed_operations_test.go]]).", + "Resolves the base URL from [[code:AZD_MANAGED_AGENT_BASE_URL]] when set, " + "otherwise falls back to [[code:https://management.azure.com]].", + "Resolves the credential via [[code:newAgentCredentialOrNil]]: nil for localhost targets " + "(so devs do not need an Azure login to talk to a local backend), otherwise the standard " + "agent credential. Credential-construction failures are intentionally swallowed and " + "surfaced as nil so the underlying HTTP 401/403 becomes the user-visible error \u2014 " + "that error is more actionable than a generic \u201cfailed to create credential\u201d wrap.", + ], + ) + + add_h3(doc, "[[code:isLocalBackendBaseURL(baseURL)]]") + add_para( + doc, + "Hostname-only prefix check for [[code:localhost]], [[code:127.0.0.1]], and [[code:[::1]]] " + "with either [[code:http://]] or [[code:https://]]. Non-standard ports still match. Used " + "by both the credential decision above and the [[code:project_endpoint]] validator bypass.", + ) + + add_h3(doc, "Environment variable reference") + add_table( + doc, + ["Variable", "Type", "Required", "Used by", "Notes"], + [ + ["[[code:AZD_MANAGED_AGENT_SUBSCRIPTION_ID]]", "string", "yes (managed ops)", "[[code:newManagedAgentClientFromEnv]]", "Azure subscription id hosting the workspace."], + ["[[code:AZD_MANAGED_AGENT_RESOURCE_GROUP]]", "string", "yes (managed ops)", "[[code:newManagedAgentClientFromEnv]]", "Resource group containing the workspace."], + ["[[code:AZD_MANAGED_AGENT_WORKSPACE]]", "string", "yes (managed ops)", "[[code:newManagedAgentClientFromEnv]]", "Workspace name. Path-escaped before being placed in the route prefix."], + ["[[code:AZD_MANAGED_AGENT_BASE_URL]]", "string", "no", "[[code:newManagedAgentClientFromEnv]]", "Overrides the default [[code:https://management.azure.com]] origin. Used to point at a local [[code:managed-harness]] dev backend ([[code:http://localhost:5000]])."], + ["[[code:AZD_FOUNDRY_ENDPOINT_OVERRIDE]]", "presence", "no", "[[code:project_endpoint]] validator", "When set to any non-empty value, the validator accepts [[code:http://]] in addition to [[code:https://]] and skips the Foundry host-suffix check. Intentionally undocumented in user help \u2014 dev/test only."], + ["[[code:FOUNDRY_PROJECT_ENDPOINT]]", "string", "yes (deploy)", "azd environment", "Foundry project endpoint used at deploy/invoke time. Unchanged from the hosted flow \u2014 listed here only because the [[code:runInitManaged]] next-steps block prints how to set it."], + ], + ) + + +def build_local_dev(doc: Document) -> None: + add_h1(doc, "Local Development Against the [[code:managed-harness]] Backend") + add_para( + doc, + "The vienna [[code:managed-harness]] service implements the same v2.0 controller as " + "production and is the recommended local backend. To target it from azd:", + ) + add_code_block( + doc, + """\ +# 1) start managed-harness locally +# (default port 5000; see vienna repo for details) + +# 2) bypass the strict project-endpoint validator so http://localhost is accepted +$Env:AZD_FOUNDRY_ENDPOINT_OVERRIDE = "1" + +# 3) point the managed client at the local origin (anything in {localhost,127.0.0.1,::1} works) +$Env:AZD_MANAGED_AGENT_BASE_URL = "http://localhost:5000" + +# 4) supply the ARM workspace tuple (the harness validates shape but does not call ARM) +$Env:AZD_MANAGED_AGENT_SUBSCRIPTION_ID = "00000000-0000-0000-0000-000000000000" +$Env:AZD_MANAGED_AGENT_RESOURCE_GROUP = "local-rg" +$Env:AZD_MANAGED_AGENT_WORKSPACE = "local-ws" + +# 5) scaffold a managed agent and deploy +azd ai agent init # choose "Managed agent" +azd env set FOUNDRY_PROJECT_ENDPOINT http://localhost:5000/api/projects/local +azd deploy +""", + ) + add_para( + doc, + "Because [[code:isLocalBackendBaseURL]] returns true for any of these origins, the " + "[[code:ManagedAgentClient]] is constructed without a credential and the bearer-token " + "policy is omitted from the pipeline \u2014 no Azure login is required.", + ) + + +def build_testing(doc: Document) -> None: + add_h1(doc, "Testing Strategy") + add_bullets( + doc, + [ + "[[code:agent_yaml/managed_test.go]] \u2014 round-trip YAML \u2192 [[code:ManagedAgent]] " + "\u2192 YAML; validator coverage for missing [[code:model]] / [[code:instructions]]; " + "discriminator routing through [[code:parse.go]].", + "[[code:agent_yaml/map_test.go]] \u2014 [[code:CreateManagedAgentAPIRequest]] populates " + "[[code:ManagedAgentDefinition]] correctly, copies skills/policies, and propagates " + "build-time env vars into [[code:ManagedEnvironment]].", + "[[code:agent_api/managed_operations_test.go]] \u2014 [[code:BuildWorkspaceRoutePrefix]] " + "input validation; [[code:httptest]]-backed coverage for every lifecycle and responses " + "URL (including the 204 path on [[code:DeleteAgent]] and the [[code:force]] query param); " + "header forwarding on [[code:CreateResponse]]; credential-nil pipeline construction.", + "[[code:cmd/project_endpoint_test.go]] \u2014 the [[code:AZD_FOUNDRY_ENDPOINT_OVERRIDE]] " + "bypass accepts [[code:http://]] and skips the host-suffix check only when set.", + "[[code:cmd/managed_dispatch_test.go]] \u2014 [[code:isManagedAgentYAML]] handles " + "missing/malformed/non-managed/managed files; [[code:newManagedAgentClientFromEnv]] " + "returns typed validation errors with actionable suggestions when env vars are missing.", + ], + ) + + +def build_open_questions(doc: Document) -> None: + add_h1(doc, "Open Questions and Future Work") + add_bullets( + doc, + [ + "Wire [[code:azd ai agent show]] / [[code:list]] / [[code:invoke]] for managed agents. " + "Designed but deferred this milestone. [[code:invoke]] in particular wants to lean on " + "[[code:CreateResponse]] + [[code:GetResponse]] streaming.", + "Surface advanced [[code:ManagedAgentDefinition]] fields through YAML: " + "[[code:structured_inputs]], [[code:files]], and the full [[code:environment]] block " + "(image, base_image, packages, cpu/memory, egress_policy). The wire types already accept " + "them; only the [[code:ManagedAgent]] YAML struct intentionally omits them today.", + "Automatic ARM workspace discovery from a Foundry project endpoint. Today the user " + "must set three env vars explicitly. A future iteration could derive the workspace tuple " + "from a project endpoint + credential via a lightweight Foundry control-plane lookup.", + "Hosted versioning parity. Whether the v2.0 controller will gain a per-version delete is " + "an open product question. Today [[code:--version]] is a typed validation error.", + "Publish [[code:ManagedAgent.yaml]] in [[code:microsoft/AgentSchema]] so the schema URL " + "in the [[code:yaml-language-server]] annotation resolves to a real document.", + "Telemetry: emit a [[code:azd.ext.azure.ai.agents.kind]] field on init/delete events " + "so we can measure managed adoption distinct from hosted.", + ], + ) + + +def build_references(doc: Document) -> None: + add_h1(doc, "References") + add_bullets( + doc, + [ + "[[code:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go]] " + "\u2014 [[code:ManagedAgent]] struct, [[code:AgentKindManaged]] constant.", + "[[code:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go]] " + "\u2014 discriminator switch.", + "[[code:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go]] " + "\u2014 [[code:CreateManagedAgentAPIRequest]].", + "[[code:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go]] " + "\u2014 [[code:ManagedAgentDefinition]] / [[code:ManagedEnvironment]] / " + "[[code:ManagedPackages]].", + "[[code:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go]] " + "\u2014 [[code:ManagedAgentClient]] + [[code:BuildWorkspaceRoutePrefix]].", + "[[code:cli/azd/extensions/azure.ai.agents/internal/cmd/init.go]] \u2014 kind prompt insertion site.", + "[[code:cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go]] " + "\u2014 [[code:promptAgentKind]].", + "[[code:cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go]] " + "\u2014 [[code:runInitManaged]] scaffolding.", + "[[code:cli/azd/extensions/azure.ai.agents/internal/cmd/managed_dispatch.go]] " + "\u2014 dispatch helpers + env-var constants.", + "[[code:cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go]] " + "\u2014 [[code:runManagedDelete]].", + "[[code:cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go]] " + "\u2014 [[code:AZD_FOUNDRY_ENDPOINT_OVERRIDE]] bypass.", + ], + ) + + +# ----------------------------- assemble ----------------------------- + + +def build_doc() -> Document: + doc = Document() + + # Defaults + style = doc.styles["Normal"] + style.font.name = "Calibri" + style.font.size = Pt(11) + + _ensure_code_style(doc) + _ensure_inline_code_style(doc) + + build_title(doc) + build_overview(doc) + build_goals(doc) + build_user_stories(doc) + build_architecture(doc) + build_yaml_schema(doc) + build_wire_contract(doc) + build_url_surface(doc) + build_cli_surface(doc) + build_dispatch_and_envvars(doc) + build_local_dev(doc) + build_testing(doc) + build_open_questions(doc) + build_references(doc) + + return doc + + +def main() -> None: + out_dir = os.path.dirname(os.path.abspath(__file__)) + out_path = os.path.join(out_dir, "spec.docx") + doc = build_doc() + doc.save(out_path) + print(f"Wrote {out_path}") + + +if __name__ == "__main__": + main() diff --git a/docs/specs/managed-harness-agents/managed-agents-getting-started.docx b/docs/specs/managed-harness-agents/managed-agents-getting-started.docx new file mode 100644 index 0000000000000000000000000000000000000000..52af9b51fa049205ad209e8b3683002008961f4e GIT binary patch literal 39145 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 literal 0 HcmV?d00001 diff --git a/docs/specs/managed-harness-agents/managed-agents-getting-started.md b/docs/specs/managed-harness-agents/managed-agents-getting-started.md new file mode 100644 index 00000000000..9cd800a7355 --- /dev/null +++ b/docs/specs/managed-harness-agents/managed-agents-getting-started.md @@ -0,0 +1,178 @@ +# Managed (Harness) Agents — Getting Started + +Audience: early-access customers evaluating managed prompt agents on Microsoft Foundry. This guide shows two ways to create and call a managed agent: the **azd CLI** (`azd ai agent`) and the **Python SDK** (`azure-ai-projects`). + +A managed agent (a "prompt agent" with `harness=ghcp`) declares only a model and instructions. Foundry provisions and runs the Brain+Hand sandbox for you — there is no container to build and no code to host. Agents live on a Foundry project and are invoked through the OpenAI-shape **Responses** API. + +--- + +## Prerequisites + +- An Azure subscription and a Foundry **project** (an `Microsoft.CognitiveServices/accounts/projects` resource, kind `AIServices`). +- A model deployment in that project (e.g. `gpt-4.1-mini`). +- `azd auth login` / `az login` access to the subscription. + +You will need the project endpoint and a model deployment name: + +- `AZURE_AI_PROJECT_ENDPOINT` = `https://.services.ai.azure.com/api/projects/` +- `AZURE_AI_MODEL_DEPLOYMENT_NAME` = e.g. `gpt-4.1-mini` + +--- + +## Option A — azd CLI + +### 1. Install + +```powershell +# Install azd +winget install microsoft.azd + +# Install the azd extensions developer extension +azd extension install microsoft.azd.extensions + +# Add the dev registry for the bug bash +azd extension source add --name MHA-dev --type url --location https://raw.githubusercontent.com/kshitij-microsoft/azure-dev/refs/heads/kchawla/azd-managed-harness/cli/azd/extensions/registry.json + +# Install the agents extension from that registry +azd extension install azure.ai.agents --source MHA-dev + +# Sign in +azd auth login +``` + +### 2. Initialize a managed agent + +```powershell +azd ai agent init +``` + +When prompted, choose **Prompt agent** (managed), pick your subscription and existing Foundry project, choose a model deployment, and name the agent. This scaffolds: + +- `agent.yaml` — `kind: managed`, the model, and the instructions. +- `azure.yaml` — a service entry (`host: azure.ai.agent`) with a `promptAgent` block. + +### 3. Deploy, list, show, invoke + +```powershell +# Provision (if needed) and create the agent on the project +azd up + +# List managed agents on the project +azd ai agent list + +# Show status of the resolved agent +azd ai agent show + +# Send a message +azd ai agent invoke "hello, what is your name?" +``` + +`list`/`show`/`invoke`/`delete` resolve the same Foundry project the agent was created on. `azd down` removes the agent along with the project resources. + +--- + +## Option B — Python SDK + +### 1. Install + +In your virtual environment: + +```powershell +pip install azure-ai-projects==2.3.0a20260625001 --extra-index-url https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple +pip install azure-identity python-dotenv +``` + +Set the endpoint and model (env vars or a `.env` file): + +```text +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4.1-mini +``` + +### 2. Create the client + +```python +import os +from dotenv import load_dotenv +from azure.identity import DefaultAzureCredential +from azure.ai.projects import AIProjectClient +from azure.ai.projects.models import PromptAgentDefinition, AgentHarness + +load_dotenv() + +endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] +model_name = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + +credential = DefaultAzureCredential() +project_client = AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) +``` + +### 3. Create a managed agent version + +The `harness=AgentHarness.GHCP` field routes the agent to the managed (GHCP) runtime instead of the default prompt-agent runtime. + +```python +agent_name = "my-managed-agent" + +created = project_client.agents.create_version( + agent_name=agent_name, + definition=PromptAgentDefinition( + model=model_name, + instructions="You are a helpful assistant.", + harness=AgentHarness.GHCP, + ), + description="Prompt agent running on the GHCP managed runtime.", + metadata={"sample": "agent_harness"}, +) +print(created) +``` + +### 4. Invoke via the Responses API + +Reference the agent by name + version through the OpenAI-compatible client: + +```python +openai_client = project_client.get_openai_client() + +response = openai_client.responses.create( + input=[{"role": "user", "content": "Generate the python code to print the OS and execute it."}], + store=False, + extra_body={"agent_reference": {"name": "my-managed-agent", "version": "1", "type": "agent_reference"}}, +) +print(response) +``` + +--- + +## What the response looks like + +Invocations stream Server-Sent Events from the project data-plane: + +```text +POST https://.services.ai.azure.com/api/projects//openai/v1/responses +content-type: text/event-stream +x-agent-session-id: ses_... + +event: response.created +data: {"type":"response.created","response":{"model":"gpt-5.4","status":"in_progress", ...}} +event: response.output_text.delta +data: {"type":"response.output_text.delta","delta":"..."} +... +event: response.completed +data: {"type":"response.completed", ...} +``` + +The Brain plans the turn and the Hand sandbox executes any tools/code; only `response.output_text.delta` events carry user-visible text. The `x-agent-session-id` header identifies the session for follow-up turns. + +--- + +## Quick reference + +| Step | CLI | SDK | +|---|---|---| +| Create agent | `azd ai agent init` + `azd up` | `agents.create_version(..., harness=AgentHarness.GHCP)` | +| Invoke | `azd ai agent invoke "..."` | `openai_client.responses.create(..., extra_body={agent_reference})` | +| List / show | `azd ai agent list` / `show` | `agents.list()` / `agents.get_version(...)` | +| Endpoint | from `azure.yaml` / env | `AZURE_AI_PROJECT_ENDPOINT` | + +Both paths target the same Foundry project; an agent created via the SDK is visible to the CLI and vice versa. diff --git a/docs/specs/managed-harness-agents/spec.docx b/docs/specs/managed-harness-agents/spec.docx new file mode 100644 index 0000000000000000000000000000000000000000..97bddbbb884b20d6026a26af4f801ad67edfa6d8 GIT binary patch literal 48114 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%0AXeFBV~N6!2~dDu{6urdp*VXu2kvRS++KD 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

Uk# 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 E18FY%jQ{`u literal 0 HcmV?d00001 diff --git a/docs/specs/managed-harness-agents/~$naged-agents-getting-started.docx b/docs/specs/managed-harness-agents/~$naged-agents-getting-started.docx new file mode 100644 index 0000000000000000000000000000000000000000..8b4b0a20701c82e281128a56d203679927d290d7 GIT binary patch literal 162 zcmd<{F3!j-$;?u4&PXiJNn{`n@G*EZ6fTAf^uQv6EhNvcCp`ObeVmLzp{aWVeX35y4^s2#%zfG(qh>s0K7jRJOBUy literal 0 HcmV?d00001 From a00e569669b4a990b27569cb21e310fdf33c2194 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Fri, 10 Jul 2026 03:28:54 -0700 Subject: [PATCH 04/24] MHA with skills and toolboxes --- .../extensions/azure.ai.agents/CHANGELOG.md | 11 + .../internal/cmd/init_managed.go | 65 ++- .../internal/cmd/init_managed_test.go | 72 ++++ .../azure.ai.agents/internal/cmd/listen.go | 25 ++ .../pkg/agents/agent_yaml/managed_test.go | 10 +- .../internal/pkg/agents/agent_yaml/parse.go | 22 +- .../agents/agent_yaml/prompt_schema_test.go | 117 +++++ .../internal/pkg/agents/agent_yaml/yaml.go | 81 +++- .../pkg/azure/foundry_files_client.go | 196 +++++++++ .../pkg/azure/foundry_files_client_test.go | 100 +++++ .../pkg/azure/foundry_projects_client.go | 66 +++ .../pkg/azure/foundry_skills_client.go | 140 ++++++ .../pkg/azure/foundry_skills_client_test.go | 70 +++ .../pkg/azure/foundry_toolsets_client.go | 5 + .../internal/project/prompt_connections.go | 381 +++++++++++++++++ .../project/prompt_connections_test.go | 211 +++++++++ .../project/prompt_convention_test.go | 140 ++++++ .../internal/project/prompt_deployment.go | 88 ++++ .../project/prompt_deployment_test.go | 151 +++++++ .../internal/project/prompt_files.go | 253 +++++++++++ .../internal/project/prompt_files_test.go | 227 ++++++++++ .../internal/project/prompt_graph.go | 210 +++++++++ .../internal/project/prompt_skills.go | 402 ++++++++++++++++++ .../internal/project/prompt_skills_test.go | 250 +++++++++++ .../internal/project/service_target_prompt.go | 72 ++++ 25 files changed, 3354 insertions(+), 11 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index f63bf0bda84..7c899d02dbe 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,5 +1,16 @@ # Release History +## Unreleased + +- Prompt (kind: managed) agents now support a convention-over-configuration deploy pipeline. `azd up` resolves an internal dependency graph before publishing the agent and validates the whole graph first so a failure never leaves a half-wired agent: + - A sibling `instructions.md` supplies the agent's instructions when none are declared inline (inline wins). + - A non-empty `files/` folder is uploaded to a vector store and wired into an auto-added `file_search` tool (content-hash dedupe; existing `file_search` tools are merged, not duplicated). + - A non-empty `skills/` folder registers each `SKILL.md` bundle into a Foundry toolbox version and attaches its MCP endpoint as an `mcp` tool; an explicit `toolbox:` reference attaches an existing toolbox instead. + - A `connections:` block resolves through a precedence ladder (use existing, create-if-missing with Entra default, auto-fill target from provisioning outputs, or provision/fail-fast), and each tool's required role is surfaced for assignment. + - The model deployment is create-if-missing, and container-only fields (`image`, `protocols`, `code_configuration`, …) are rejected for prompt agents. + - The manifest parser recognizes `skill` and `file` resource kinds. +- `azd ai agent init` now scaffolds the prompt-agent authoring layout: an `instructions.md` sidecar (instructions are written there instead of inline in `agent.yaml`) plus empty `files/` and `skills/` folders so the deploy conventions are discoverable from a fresh init. + ## 0.1.41-preview (2026-06-19) - [[#8731]](https://github.com/Azure/azure-dev/pull/8731) Improve the post-deploy `Next:` guidance with a stacked layout that puts each command on its own line above its description, adds a blank line between suggestions, and highlights `azd` commands. The new layout applies across deploy, `azd ai agent show`, `init`, and `doctor`. Thanks @therealjohn for the contribution! diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go index 64b76a37797..41fd60c4ae0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go @@ -143,8 +143,10 @@ func runInitManaged( Name: agentName, Kind: agent_yaml.AgentKindManaged, }, - Model: model, - Instructions: instructions, + Model: model, + // Instructions are written to a sibling instructions.md by the + // convention scaffolding below, so they are omitted inline here. The + // deploy engine reads instructions.md when no inline value is present. } if strings.TrimSpace(description) != "" { desc := strings.TrimSpace(description) @@ -154,6 +156,13 @@ func runInitManaged( return err } + // Scaffold the convention-based authoring layout (instructions.md + empty + // files/ and skills/ folders) so the deploy engine's folder conventions are + // discoverable from a fresh init. + if err := scaffoldPromptConventionFolders(serviceRelPath, instructions); err != nil { + return err + } + if err := addPromptAgentService(ctx, azdClient, agentName, serviceRelPath, &settings, deployment); err != nil { return err } @@ -425,6 +434,47 @@ func writeManagedAgentYAML(targetDir string, managedAgent *agent_yaml.ManagedAge return nil } +// scaffoldPromptConventionFolders writes the convention-based authoring layout +// next to agent.yaml so the deploy engine's folder conventions are discoverable +// from a fresh init: +// +// - instructions.md — the agent's instructions (deploy uses this when the +// agent.yaml has no inline instructions). +// - files/ — drop documents here to get file_search automatically. +// - skills/ — add one subfolder per skill (each with a SKILL.md). +// +// The empty folders are kept with a .gitkeep placeholder. The deploy scanners +// ignore dotfiles, so .gitkeep never contributes content. An existing +// instructions.md is never overwritten so re-running init preserves edits. +func scaffoldPromptConventionFolders(targetDir, instructions string) error { + if strings.TrimSpace(instructions) == "" { + instructions = "You are a helpful AI assistant." + } + + instructionsPath := filepath.Join(targetDir, "instructions.md") + if !fileExists(instructionsPath) { + content := strings.TrimRight(instructions, "\n") + "\n" + if err := os.WriteFile(instructionsPath, []byte(content), osutil.PermissionFile); err != nil { + return fmt.Errorf("writing instructions.md: %w", err) + } + log.Printf("Wrote instructions.md at %s", instructionsPath) + } + + for _, sub := range []string{"files", "skills"} { + dir := filepath.Join(targetDir, sub) + if err := os.MkdirAll(dir, osutil.PermissionDirectory); err != nil { + return fmt.Errorf("creating %s folder: %w", sub, err) + } + keep := filepath.Join(dir, ".gitkeep") + if !fileExists(keep) { + if err := os.WriteFile(keep, []byte{}, osutil.PermissionFile); err != nil { + return fmt.Errorf("writing %s/.gitkeep: %w", sub, err) + } + } + } + return nil +} + // printManagedInitSummary prints a concise summary plus next-step hint. func printManagedInitSummary( agentName, model, serviceRelPath, projectTargetDir string, @@ -450,6 +500,17 @@ func printManagedInitSummary( fmt.Printf(" Model endpoint: %s\n", settings.ModelEndpoint) } + // Point at the convention-based authoring layout the scaffold created. + dirPrefix := "" + if serviceRelPath != "." { + dirPrefix = filepath.ToSlash(serviceRelPath) + "/" + } + fmt.Println() + fmt.Println("Authoring layout (edit these to add capabilities):") + fmt.Printf(" %sinstructions.md the agent's instructions\n", dirPrefix) + fmt.Printf(" %sfiles/ drop documents here for automatic file search\n", dirPrefix) + fmt.Printf(" %sskills/ add a subfolder per skill (each with a SKILL.md)\n", dirPrefix) + fmt.Println() fmt.Println("Next steps:") if !existingProject && projectTargetDir != "." { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go new file mode 100644 index 00000000000..0d9b745655a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +func TestScaffoldPromptConventionFolders_CreatesLayout(t *testing.T) { + dir := t.TempDir() + + if err := scaffoldPromptConventionFolders(dir, "You are a triage assistant."); err != nil { + t.Fatalf("scaffoldPromptConventionFolders: %v", err) + } + + // instructions.md carries the provided instructions. + content, err := os.ReadFile(filepath.Join(dir, "instructions.md")) + if err != nil { + t.Fatalf("read instructions.md: %v", err) + } + if string(content) != "You are a triage assistant.\n" { + t.Errorf("instructions.md content: got %q", string(content)) + } + + // files/ and skills/ exist with a .gitkeep placeholder. + for _, sub := range []string{"files", "skills"} { + info, statErr := os.Stat(filepath.Join(dir, sub)) + if statErr != nil || !info.IsDir() { + t.Errorf("%s/ should be a directory: %v", sub, statErr) + } + if _, keepErr := os.Stat(filepath.Join(dir, sub, ".gitkeep")); keepErr != nil { + t.Errorf("%s/.gitkeep should exist: %v", sub, keepErr) + } + } +} + +func TestScaffoldPromptConventionFolders_DefaultInstructions(t *testing.T) { + dir := t.TempDir() + if err := scaffoldPromptConventionFolders(dir, " "); err != nil { + t.Fatalf("scaffoldPromptConventionFolders: %v", err) + } + content, err := os.ReadFile(filepath.Join(dir, "instructions.md")) + if err != nil { + t.Fatalf("read instructions.md: %v", err) + } + if string(content) != "You are a helpful AI assistant.\n" { + t.Errorf("default instructions: got %q", string(content)) + } +} + +func TestScaffoldPromptConventionFolders_DoesNotOverwriteInstructions(t *testing.T) { + dir := t.TempDir() + existing := "MY EDITED INSTRUCTIONS\n" + if err := os.WriteFile(filepath.Join(dir, "instructions.md"), []byte(existing), 0o600); err != nil { + t.Fatalf("seed instructions.md: %v", err) + } + + if err := scaffoldPromptConventionFolders(dir, "should be ignored"); err != nil { + t.Fatalf("scaffoldPromptConventionFolders: %v", err) + } + + content, err := os.ReadFile(filepath.Join(dir, "instructions.md")) + if err != nil { + t.Fatalf("read instructions.md: %v", err) + } + if string(content) != existing { + t.Errorf("existing instructions.md should be preserved, got %q", string(content)) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index a0f15a102db..28a886ced77 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -57,9 +57,15 @@ func configureExtensionHost(host *azdext.ExtensionHost) { } func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { + agentServiceCount := 0 + hostedAgentCount := 0 for _, svc := range args.Project.Services { switch svc.Host { case AiAgentHost: + agentServiceCount++ + if isHostedAgentService(svc, args.Project) { + hostedAgentCount++ + } // Prompt (kind=managed) agents have no container to provision // settings for — the harness owns the runtime. But they DO carry a // model deployment in their service config, so still run envUpdate @@ -77,6 +83,25 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args } } + // Reconcile ENABLE_HOSTED_AGENTS for the project. kindEnvUpdate sets it to + // "true" for hosted agents but never clears it, so a project that once had a + // hosted agent and now has only prompt (kind=managed) agents would keep a + // stale "true" — which makes the starter Bicep provision an ACR plus role + // assignments the user may not be permitted to create. When there is at + // least one agent service and none are hosted, force it to "false" so a + // prompt-only project never provisions hosted-agent infrastructure. + if agentServiceCount > 0 && hostedAgentCount == 0 { + envName, err := currentEnvName(ctx, azdClient) + if err != nil { + return fmt.Errorf("failed to look up current environment: %w", err) + } + if envName != "" { + if err := setEnvVar(ctx, azdClient, envName, "ENABLE_HOSTED_AGENTS", "false"); err != nil { + return fmt.Errorf("failed to set ENABLE_HOSTED_AGENTS=false: %w", err) + } + } + } + return nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go index 536176f185b..00b74b35d7b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go @@ -90,8 +90,9 @@ func TestManagedAgent_YAMLRoundTrip(t *testing.T) { } // TestValidateAgentDefinition_Managed_RequiresModelAndInstructions ensures the -// validator surfaces actionable errors when required managed-agent fields are -// missing. +// validator requires a model for managed agents. Instructions are intentionally +// not required inline (they may come from a sibling instructions.md), so an +// agent.yaml without inline instructions must still validate here. func TestValidateAgentDefinition_Managed_RequiresModelAndInstructions(t *testing.T) { cases := []struct { name string @@ -110,14 +111,13 @@ instructions: ok shouldError: true, }, { - name: "missing instructions", + name: "missing inline instructions is allowed (may come from instructions.md)", yamlContent: ` name: n kind: managed model: gpt-4.1-mini `, - wantSubstr: "instructions", - shouldError: true, + shouldError: false, }, { name: "valid", diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go index ac6a528b9f2..7523995abb2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go @@ -181,6 +181,18 @@ func ExtractResourceDefinitions(manifestYamlContent []byte) ([]any, error) { return nil, fmt.Errorf("failed to unmarshal to ConnectionResource: %w", err) } resourceDefs = append(resourceDefs, connDef) + case ResourceKindSkill: + var skillDef SkillResource + if err := yaml.Unmarshal(resourceBytes, &skillDef); err != nil { + return nil, fmt.Errorf("failed to unmarshal to SkillResource: %w", err) + } + resourceDefs = append(resourceDefs, skillDef) + case ResourceKindFile: + var fileDef FileResource + if err := yaml.Unmarshal(resourceBytes, &fileDef); err != nil { + return nil, fmt.Errorf("failed to unmarshal to FileResource: %w", err) + } + resourceDefs = append(resourceDefs, fileDef) default: return nil, fmt.Errorf("unrecognized resource kind: %s", resourceDef.Kind) } @@ -432,9 +444,13 @@ func ValidateAgentDefinition(templateBytes []byte) error { if strings.TrimSpace(agent.Model) == "" { errors = append(errors, "template.model is required for managed agents") } - if strings.TrimSpace(agent.Instructions) == "" { - errors = append(errors, "template.instructions is required for managed agents") - } + // Instructions are intentionally NOT required inline here: + // prompt agents may supply them via a sibling instructions.md + // file (the deploy engine reads it when the inline value is + // empty). The deploy-time graph validation enforces that + // instructions are present from one source or the other, so a + // truly instruction-less agent is still rejected — just with a + // clearer, convention-aware message. for i, policy := range agent.Policies { switch policy.Type { case PolicyTypeRai: diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go new file mode 100644 index 00000000000..7357d7e3fbb --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "testing" + + "go.yaml.in/yaml/v3" +) + +// TestManagedAgent_ConnectionsRoundTrip verifies the prompt-agent `connections:` +// block parses into ManagedAgent.Connections and round-trips through YAML. +func TestManagedAgent_ConnectionsRoundTrip(t *testing.T) { + yamlContent := []byte(` +kind: managed +name: conn-agent +model: gpt-4.1-mini +instructions: You are helpful. +connections: + - name: aisearch-conn + category: CognitiveSearch + target: https://my-search.search.windows.net + authType: Entra + - name: apikey-conn + category: RemoteTool + authType: ApiKey + credentials: + key: ${SEARCH_API_KEY} + provision: true +`) + + var managed ManagedAgent + if err := yaml.Unmarshal(yamlContent, &managed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(managed.Connections) != 2 { + t.Fatalf("connections: got %d, want 2", len(managed.Connections)) + } + + first := managed.Connections[0] + if first.Name != "aisearch-conn" || first.Category != "CognitiveSearch" { + t.Errorf("first connection: got %+v", first) + } + if first.Target != "https://my-search.search.windows.net" || first.AuthType != "Entra" { + t.Errorf("first connection target/auth: got %+v", first) + } + + second := managed.Connections[1] + if second.AuthType != "ApiKey" || !second.Provision { + t.Errorf("second connection: got %+v", second) + } + if second.Credentials["key"] != "${SEARCH_API_KEY}" { + t.Errorf("second connection credentials: got %+v", second.Credentials) + } + + // Round-trip: marshal then unmarshal and confirm the count is preserved. + data, err := yaml.Marshal(managed) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var again ManagedAgent + if err := yaml.Unmarshal(data, &again); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + if len(again.Connections) != 2 { + t.Fatalf("round-tripped connections: got %d, want 2", len(again.Connections)) + } +} + +// TestExtractResourceDefinitions_SkillAndFileKinds verifies the manifest parser +// recognizes the `skill` and `file` resource kinds and decodes them into their +// typed resources. +func TestExtractResourceDefinitions_SkillAndFileKinds(t *testing.T) { + manifest := []byte(` +name: m +resources: + - kind: skill + name: agentdevcompute + path: skills/agentdevcompute + version: "1.2.0" + - kind: file + name: handbook + path: files/handbook.pdf + purpose: assistants +`) + + resources, err := ExtractResourceDefinitions(manifest) + if err != nil { + t.Fatalf("ExtractResourceDefinitions: %v", err) + } + if len(resources) != 2 { + t.Fatalf("resources: got %d, want 2", len(resources)) + } + + skill, ok := resources[0].(SkillResource) + if !ok { + t.Fatalf("resource[0]: got %T, want SkillResource", resources[0]) + } + if skill.Kind != ResourceKindSkill || skill.Name != "agentdevcompute" { + t.Errorf("skill resource: got %+v", skill) + } + if skill.Path != "skills/agentdevcompute" || skill.Version != "1.2.0" { + t.Errorf("skill path/version: got %+v", skill) + } + + file, ok := resources[1].(FileResource) + if !ok { + t.Fatalf("resource[1]: got %T, want FileResource", resources[1]) + } + if file.Kind != ResourceKindFile || file.Path != "files/handbook.pdf" { + t.Errorf("file resource: got %+v", file) + } + if file.Purpose != "assistants" { + t.Errorf("file purpose: got %q", file.Purpose) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index bcf66cb4202..607f67ba5a2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -44,6 +44,8 @@ const ( ResourceKindTool ResourceKind = "tool" ResourceKindToolbox ResourceKind = "toolbox" ResourceKindConnection ResourceKind = "connection" + ResourceKindSkill ResourceKind = "skill" + ResourceKindFile ResourceKind = "file" ) type ToolKind string @@ -251,7 +253,9 @@ type ManagedAgent struct { Model string `json:"model" yaml:"model"` // Instructions is the system/developer message inserted into the model's context. - Instructions string `json:"instructions" yaml:"instructions"` + // It may be omitted here when supplied by a sibling instructions.md file + // (the deploy engine falls back to that convention); inline always wins. + Instructions string `json:"instructions,omitempty" yaml:"instructions,omitempty"` // Skills is an optional list of Foundry skill names attached to the agent. Skills []string `json:"skills,omitempty" yaml:"skills,omitempty"` @@ -277,6 +281,61 @@ type ManagedAgent struct { // Policies is an optional list of governance policies (e.g. RAI). Policies []Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + + // Connections declares project connections that the agent's tools depend on. + // The deploy engine resolves each connection through the resolution ladder + // (reference existing, create-if-missing, auto-fill target, provision) and + // assigns the required role. Only tools that need external wiring reference a + // connection by name; connections themselves are declared here once. + Connections []PromptConnection `json:"connections,omitempty" yaml:"connections,omitempty"` + + // Toolbox optionally references an existing Foundry toolbox by name and + // version. When set, the deploy engine attaches that toolbox's MCP endpoint + // as an mcp tool instead of registering skills from the skills/ folder. + Toolbox *ToolboxReference `json:"toolbox,omitempty" yaml:"toolbox,omitempty"` +} + +// ToolboxReference points at an existing Foundry toolbox version so a prompt +// agent can consume it without the deploy engine registering local skills. +type ToolboxReference struct { + // Name is the toolbox name. + Name string `json:"name" yaml:"name"` + + // Version is the toolbox version. When empty the toolbox's default version + // is used. + Version string `json:"version,omitempty" yaml:"version,omitempty"` +} + +// PromptConnection is a project connection declared on a prompt agent. It mirrors +// the fields the Foundry connection API accepts and is intentionally distinct +// from the AI-service Connection type used elsewhere in this package. AuthType +// defaults to Entra (secret-free) when empty; ApiKey auth reads its secret from +// Credentials. +type PromptConnection struct { + // Name is the connection name, referenced by a tool's connection field. + Name string `json:"name" yaml:"name"` + + // Category is the connection category (e.g. "CognitiveSearch", "RemoteTool"). + Category string `json:"category" yaml:"category"` + + // Target is the endpoint of the backing Azure resource. When empty, the + // deploy engine attempts to fill it from provisioning outputs. + Target string `json:"target,omitempty" yaml:"target,omitempty"` + + // AuthType selects the authentication mode ("Entra" default, or "ApiKey"). + AuthType string `json:"authType,omitempty" yaml:"authType,omitempty"` + + // Credentials carries auth material for non-Entra auth (e.g. an API key, + // possibly as a ${ENV_VAR} reference resolved at deploy time). + Credentials map[string]any `json:"credentials,omitempty" yaml:"credentials,omitempty"` + + // Metadata is optional additional connection metadata. + Metadata map[string]string `json:"metadata,omitempty" yaml:"metadata,omitempty"` + + // Provision opts into the deploy engine creating the backing Azure resource + // (via an emitted Bicep module) when no existing connection or target can be + // resolved. Defaults to false (fail fast with guidance). + Provision bool `json:"provision,omitempty" yaml:"provision,omitempty"` } // AgentManifest The following represents a manifest that can be used to create agents dynamically. @@ -810,6 +869,26 @@ type ConnectionResource struct { ConnectorName string `json:"connectorName,omitempty" yaml:"connectorName,omitempty"` } +// SkillResource Represents a skill bundle required by the agent. Skills are +// normally discovered by convention from a local `skills/` folder, but a +// manifest may declare one explicitly. Path points at the skill bundle +// directory (containing SKILL.md); Version pins the registered skill version. +type SkillResource struct { + Resource `json:",inline" yaml:",inline"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` +} + +// FileResource Represents a file (or folder of files) contributed to the +// agent's vector store. Files are normally discovered by convention from a +// local `files/` folder, but a manifest may declare one explicitly. Path points +// at a file or directory; Purpose is the optional Foundry Files purpose. +type FileResource struct { + Resource `json:",inline" yaml:",inline"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` + Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"` +} + // Template Template model for defining prompt templates. // This model specifies the rendering engine used for slot filling prompts, // the parser used to process the rendered template into API-compatible format, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client.go new file mode 100644 index 00000000000..355d4ecd6bf --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client.go @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azure + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" + + "azureaiagent/internal/version" +) + +// filesAPIPathVersion is the version path segment for the OpenAI-compatible +// Files and Vector Stores endpoints. These endpoints require the version in the +// path (/openai/v1/...) and reject an api-version query parameter. +const filesAPIPathVersion = "v1" + +// FoundryFilesClient talks to the OpenAI-compatible Files and Vector Stores +// endpoints exposed under a Foundry project data-plane endpoint. It is used by +// the prompt-agent deploy engine to turn a local `files/` folder into a vector +// store that backs a `file_search` tool. +type FoundryFilesClient struct { + endpoint string + pipeline runtime.Pipeline +} + +// NewFoundryFilesClient creates a client rooted at a Foundry project endpoint +// (e.g. https://.services.ai.azure.com/api/projects/). +func NewFoundryFilesClient(endpoint string, cred azcore.TokenCredential) *FoundryFilesClient { + userAgent := fmt.Sprintf("azd-ext-azure-ai-agents/%s", version.Version) + + clientOptions := &policy.ClientOptions{ + Logging: policy.LogOptions{ + AllowedHeaders: []string{azsdk.MsCorrelationIdHeader, "X-Request-Id"}, + }, + PerCallPolicies: []policy.Policy{ + runtime.NewBearerTokenPolicy(cred, []string{"https://ai.azure.com/.default"}, nil), + azsdk.NewMsCorrelationPolicy(), + azsdk.NewUserAgentPolicy(userAgent), + }, + } + + pipeline := runtime.NewPipeline( + "azure-ai-agents", + "v1.0.0", + runtime.PipelineOptions{}, + clientOptions, + ) + + return &FoundryFilesClient{ + endpoint: strings.TrimRight(endpoint, "/"), + pipeline: pipeline, + } +} + +// FileObject is the response for an uploaded file. +type FileObject struct { + Id string `json:"id"` + Object string `json:"object"` + Bytes int64 `json:"bytes"` + Filename string `json:"filename"` + Purpose string `json:"purpose"` +} + +// VectorStoreObject is the response for a vector store. +type VectorStoreObject struct { + Id string `json:"id"` + Object string `json:"object"` + Name string `json:"name"` +} + +// UploadFile uploads a single file's content to the Foundry Files endpoint and +// returns the created file object. purpose defaults to "assistants" when empty. +func (c *FoundryFilesClient) UploadFile( + ctx context.Context, + filename string, + content []byte, + purpose string, +) (*FileObject, error) { + if strings.TrimSpace(purpose) == "" { + purpose = "assistants" + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + if err := writer.WriteField("purpose", purpose); err != nil { + return nil, fmt.Errorf("writing purpose field: %w", err) + } + part, err := writer.CreateFormFile("file", filename) + if err != nil { + return nil, fmt.Errorf("creating file part: %w", err) + } + if _, err := part.Write(content); err != nil { + return nil, fmt.Errorf("writing file content: %w", err) + } + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("closing multipart writer: %w", err) + } + + targetURL := fmt.Sprintf("%s/openai/%s/files", c.endpoint, filesAPIPathVersion) + req, err := runtime.NewRequest(ctx, http.MethodPost, targetURL) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + if err := req.SetBody( + streaming.NopCloser(bytes.NewReader(body.Bytes())), + writer.FormDataContentType(), + ); err != nil { + return nil, fmt.Errorf("setting request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + var result FileObject + if err := decodeJSON(resp.Body, &result); err != nil { + return nil, err + } + return &result, nil +} + +// createVectorStoreRequest is the body for creating a vector store. +type createVectorStoreRequest struct { + Name string `json:"name,omitempty"` + FileIds []string `json:"file_ids"` +} + +// CreateVectorStore creates a vector store from the given file ids and returns +// the created store. name is optional but recommended for later lookup. +func (c *FoundryFilesClient) CreateVectorStore( + ctx context.Context, + name string, + fileIDs []string, +) (*VectorStoreObject, error) { + payload, err := json.Marshal(createVectorStoreRequest{Name: name, FileIds: fileIDs}) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + targetURL := fmt.Sprintf("%s/openai/%s/vector_stores", c.endpoint, filesAPIPathVersion) + req, err := runtime.NewRequest(ctx, http.MethodPost, targetURL) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + if err := req.SetBody( + streaming.NopCloser(bytes.NewReader(payload)), + "application/json", + ); err != nil { + return nil, fmt.Errorf("setting request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + var result VectorStoreObject + if err := decodeJSON(resp.Body, &result); err != nil { + return nil, err + } + return &result, nil +} + +// decodeJSON reads and unmarshals a JSON response body. +func decodeJSON(r io.Reader, v any) error { + body, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("reading response body: %w", err) + } + if err := json.Unmarshal(body, v); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client_test.go new file mode 100644 index 00000000000..0880652e3f4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client_test.go @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azure + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// newTestFilesClient builds a FoundryFilesClient backed by a custom +// round-tripper so request shapes can be asserted without the network. +func newTestFilesClient(endpoint string, fn roundTripFunc) *FoundryFilesClient { + return &FoundryFilesClient{ + endpoint: endpoint, + pipeline: newTestPipeline(fn), + } +} + +func TestUploadFile_RequestShape(t *testing.T) { + var captured *http.Request + var body []byte + + client := newTestFilesClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { + captured = req + if req.Body != nil { + body, _ = io.ReadAll(req.Body) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"file-1","filename":"faq.md","purpose":"assistants"}`)), + Header: make(http.Header), + }, nil + }) + + obj, err := client.UploadFile(t.Context(), "faq.md", []byte("hello world"), "") + require.NoError(t, err) + require.Equal(t, "file-1", obj.Id) + + require.NotNil(t, captured) + require.Equal(t, http.MethodPost, captured.Method) + require.Equal(t, "/openai/v1/files", captured.URL.EscapedPath()) + require.Empty(t, captured.URL.RawQuery) + require.Contains(t, captured.Header.Get("Content-Type"), "multipart/form-data") + + // The multipart body should carry the filename, the content, and the + // default purpose ("assistants") when none was supplied. + bodyStr := string(body) + require.Contains(t, bodyStr, "faq.md") + require.Contains(t, bodyStr, "hello world") + require.Contains(t, bodyStr, "assistants") +} + +func TestCreateVectorStore_RequestShape(t *testing.T) { + var captured *http.Request + var body []byte + + client := newTestFilesClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { + captured = req + if req.Body != nil { + body, _ = io.ReadAll(req.Body) + } + return &http.Response{ + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(`{"id":"vs-1","name":"agent","object":"vector_store"}`)), + Header: make(http.Header), + }, nil + }) + + store, err := client.CreateVectorStore(t.Context(), "agent", []string{"file-1", "file-2"}) + require.NoError(t, err) + require.Equal(t, "vs-1", store.Id) + + require.NotNil(t, captured) + require.Equal(t, http.MethodPost, captured.Method) + require.Equal(t, "/openai/v1/vector_stores", captured.URL.EscapedPath()) + require.Equal(t, "application/json", captured.Header.Get("Content-Type")) + + bodyStr := string(body) + require.Contains(t, bodyStr, `"name":"agent"`) + require.Contains(t, bodyStr, `"file-1"`) + require.Contains(t, bodyStr, `"file-2"`) +} + +func TestUploadFile_ErrorStatus(t *testing.T) { + client := newTestFilesClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`{"error":"nope"}`)), + Header: make(http.Header), + }, nil + }) + + _, err := client.UploadFile(t.Context(), "faq.md", []byte("x"), "assistants") + require.Error(t, err) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_projects_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_projects_client.go index d7435d57815..6c5c3284adf 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_projects_client.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_projects_client.go @@ -5,6 +5,7 @@ package azure import ( "azureaiagent/internal/version" + "bytes" "context" "encoding/json" "fmt" @@ -16,6 +17,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" "github.com/azure/azure-dev/cli/azd/pkg/azsdk" ) @@ -195,6 +197,70 @@ func (c *FoundryProjectsClient) GetConnectionWithCredentials(ctx context.Context return &connection, nil } +// CreateConnectionRequest is the body for creating or updating a project +// connection. It mirrors the ConnectionPropertiesV2 shape the data-plane +// accepts under a `properties` envelope. +type CreateConnectionRequest struct { + Category string `json:"category"` + Target string `json:"target"` + AuthType string `json:"authType"` + Credentials map[string]any `json:"credentials,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// CreateConnection creates (or updates) a project connection by name and +// returns the created connection. AuthType defaults to Entra/AAD when empty. +func (c *FoundryProjectsClient) CreateConnection( + ctx context.Context, + name string, + request *CreateConnectionRequest, +) (*Connection, error) { + if request.AuthType == "" { + request.AuthType = "AAD" + } + targetEndpoint := fmt.Sprintf( + "%s/connections/%s?api-version=%s", + c.baseEndpoint, url.PathEscape(name), c.apiVersion, + ) + + payload, err := json.Marshal(map[string]any{"properties": request}) + if err != nil { + return nil, fmt.Errorf("failed to marshal connection request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPut, targetEndpoint) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + if err := req.SetBody( + streaming.NopCloser(bytes.NewReader(payload)), + "application/json", + ); err != nil { + return nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var connection Connection + if err := json.Unmarshal(body, &connection); err != nil { + return nil, fmt.Errorf("failed to unmarshal connection response: %w", err) + } + return &connection, nil +} + // GetAllConnections retrieves all connections from the project, handling pagination func (c *FoundryProjectsClient) GetAllConnections(ctx context.Context) ([]Connection, error) { var allConnections []Connection diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go new file mode 100644 index 00000000000..ed0a4888793 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azure + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" + + "azureaiagent/internal/version" +) + +const ( + skillsApiVersion = "v1" + skillsFeatureHeader = "Skills=V1Preview" +) + +// FoundrySkillsClient registers Agent-Skills bundles with the Foundry skill +// data-plane so they can be referenced from a toolbox version. It is the +// primary (registration) path for turning a local skills/ folder into +// toolbox-attached skills. +type FoundrySkillsClient struct { + endpoint string + pipeline runtime.Pipeline +} + +// NewFoundrySkillsClient creates a client rooted at a Foundry project endpoint. +func NewFoundrySkillsClient(endpoint string, cred azcore.TokenCredential) *FoundrySkillsClient { + userAgent := fmt.Sprintf("azd-ext-azure-ai-agents/%s", version.Version) + + clientOptions := &policy.ClientOptions{ + Logging: policy.LogOptions{ + AllowedHeaders: []string{azsdk.MsCorrelationIdHeader, "X-Request-Id"}, + }, + PerCallPolicies: []policy.Policy{ + runtime.NewBearerTokenPolicy(cred, []string{"https://ai.azure.com/.default"}, nil), + azsdk.NewMsCorrelationPolicy(), + azsdk.NewUserAgentPolicy(userAgent), + }, + } + + pipeline := runtime.NewPipeline( + "azure-ai-agents", + "v1.0.0", + runtime.PipelineOptions{}, + clientOptions, + ) + + return &FoundrySkillsClient{ + endpoint: strings.TrimRight(endpoint, "/"), + pipeline: pipeline, + } +} + +// SkillVersionObject is the response for a registered skill version. +type SkillVersionObject struct { + Id string `json:"id"` + SkillId string `json:"skill_id"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + CreatedAt int64 `json:"created_at"` +} + +// SkillInlineContent carries the skill definition inline for the JSON create +// path. Description is the one-line summary; Instructions is the skill body +// (the Markdown under the SKILL.md frontmatter) injected into the agent. +type SkillInlineContent struct { + Description string `json:"description,omitempty"` + Instructions string `json:"instructions"` +} + +// CreateSkillVersionRequest is the body for registering a skill version via the +// JSON inline-content path. The skill name comes from the URL path; the version +// is assigned by the service. Multi-file bundles (references/, assets/) require +// the ZIP/multipart upload path instead. +type CreateSkillVersionRequest struct { + InlineContent SkillInlineContent `json:"inline_content"` +} + +// CreateSkillVersion registers (or updates) a skill at the given name and +// returns the created version. When the skill does not exist it is created. +func (c *FoundrySkillsClient) CreateSkillVersion( + ctx context.Context, + skillName string, + request *CreateSkillVersionRequest, +) (*SkillVersionObject, error) { + targetURL := fmt.Sprintf( + "%s/skills/%s/versions?api-version=%s", + c.endpoint, url.PathEscape(skillName), skillsApiVersion, + ) + + payload, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPost, targetURL) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", skillsFeatureHeader) + if err := req.SetBody( + streaming.NopCloser(bytes.NewReader(payload)), + "application/json", + ); err != nil { + return nil, fmt.Errorf("setting request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + var result SkillVersionObject + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + return &result, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go new file mode 100644 index 00000000000..17112abceea --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azure + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func newTestSkillsClient(endpoint string, fn roundTripFunc) *FoundrySkillsClient { + return &FoundrySkillsClient{ + endpoint: endpoint, + pipeline: newTestPipeline(fn), + } +} + +func TestCreateSkillVersion_RequestShape(t *testing.T) { + var captured *http.Request + var body []byte + + client := newTestSkillsClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { + captured = req + if req.Body != nil { + body, _ = io.ReadAll(req.Body) + } + return &http.Response{ + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(`{"id":"s-1","name":"my-skill","version":"1.2.0"}`)), + Header: make(http.Header), + }, nil + }) + + out, err := client.CreateSkillVersion(t.Context(), "my skill", &CreateSkillVersionRequest{ + InlineContent: SkillInlineContent{ + Description: "does things", + Instructions: "You are a helpful skill.", + }, + }) + require.NoError(t, err) + require.Equal(t, "1.2.0", out.Version) + + require.NotNil(t, captured) + require.Equal(t, http.MethodPost, captured.Method) + require.Equal(t, "/skills/my%20skill/versions", captured.URL.EscapedPath()) + require.Equal(t, "api-version="+skillsApiVersion, captured.URL.RawQuery) + require.Equal(t, skillsFeatureHeader, captured.Header.Get("Foundry-Features")) + // inline_content is an object with description + instructions (no envelope). + require.Contains(t, string(body), `"inline_content"`) + require.Contains(t, string(body), `"instructions":"You are a helpful skill."`) + require.Contains(t, string(body), `"description":"does things"`) +} + +func TestCreateSkillVersion_ErrorStatus(t *testing.T) { + client := newTestSkillsClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"error":"bad"}`)), + Header: make(http.Header), + }, nil + }) + _, err := client.CreateSkillVersion(t.Context(), "s", &CreateSkillVersionRequest{ + InlineContent: SkillInlineContent{Instructions: "x"}, + }) + require.Error(t, err) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go index 7c0ae152a82..06305965404 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go @@ -67,10 +67,15 @@ func NewFoundryToolboxClient( // CreateToolboxVersionRequest is the request body for creating a new toolbox version. // The toolbox name is provided in the URL path, not in the body. +// +// Skills are attached via a separate top-level `skills` array (skill references), +// distinct from `tools`. Each skill reference is +// {"type": "skill_reference", "name": , "version": }. type CreateToolboxVersionRequest struct { Description string `json:"description,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Tools []map[string]any `json:"tools"` + Skills []map[string]any `json:"skills,omitempty"` } // ToolboxObject is the lightweight response for a toolbox (no tools list). diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go new file mode 100644 index 00000000000..f26effdfdf0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go @@ -0,0 +1,381 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "net/url" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + + "github.com/azure/azure-dev/cli/azd/pkg/output" +) + +// connectionAction is the resolution outcome for a single declared connection. +type connectionAction int + +const ( + // connActionUseExisting means a connection with this name already exists in + // the project and is used as-is (ladder rung 1). + connActionUseExisting connectionAction = iota + // connActionCreate means the connection is created against a known target + // (ladder rung 2, with the target possibly auto-filled at rung 3). + connActionCreate + // connActionProvision means the backing resource must be provisioned first + // (ladder rung 4, opt-in via Provision). + connActionProvision + // connActionFailFast means nothing could be resolved and the user must act + // (ladder rung 4, no opt-in). + connActionFailFast +) + +// connectionRoleAssignments below map a tool type to the Azure role its +// connection's identity needs on the backing resource. Only tools that require +// a data-plane role are listed; others authenticate through the connection +// itself and need no role assignment. +// +// Role IDs are Azure built-in role definition GUIDs. +var toolRequiredRoles = map[string]struct { + RoleID string + RoleName string +}{ + // Search Index Data Reader. + "azure_ai_search": {"1407120a-92aa-4202-b7e9-c0e197c71c8f", "Search Index Data Reader"}, +} + +// requiredRoleForTool returns the role a tool's connection identity needs, if +// any. ok is false when the tool type needs no explicit role assignment. +func requiredRoleForTool(toolType string) (roleID, roleName string, ok bool) { + r, found := toolRequiredRoles[toolType] + if !found { + return "", "", false + } + return r.RoleID, r.RoleName, true +} + +// targetFromEnv attempts to auto-fill a connection target from azd provisioning +// outputs (ladder rung 3). It scans the connection-oriented env exports for an +// entry keyed by the connection name. Returns "" when nothing matches. +func targetFromEnv(name string, env map[string]string) string { + if env == nil || strings.TrimSpace(name) == "" { + return "" + } + // The provisioning layer exports connection targets as NAME=target pairs in + // AI_PROJECT_CONNECTIONS (semicolon-separated) and dependent resources in + // AI_PROJECT_DEPENDENT_RESOURCES. Both are scanned. + for _, key := range []string{"AI_PROJECT_CONNECTIONS", "AI_PROJECT_DEPENDENT_RESOURCES"} { + raw, ok := env[key] + if !ok || strings.TrimSpace(raw) == "" { + continue + } + for _, pair := range strings.Split(raw, ";") { + pair = strings.TrimSpace(pair) + eq := strings.IndexByte(pair, '=') + if eq <= 0 { + continue + } + if strings.EqualFold(strings.TrimSpace(pair[:eq]), name) { + return strings.TrimSpace(pair[eq+1:]) + } + } + } + return "" +} + +// resolveConnectionAction decides how to satisfy one declared connection given +// the set of existing connection names and the azd environment. It is pure and +// table-testable; the connection node performs the side effects. +// +// The returned PromptConnection carries any auto-filled target so the caller can +// create the connection without re-deriving it. +func resolveConnectionAction( + decl agent_yaml.PromptConnection, + existing map[string]string, + env map[string]string, +) (connectionAction, agent_yaml.PromptConnection, error) { + if strings.TrimSpace(decl.Name) == "" { + return connActionFailFast, decl, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "a declared connection is missing a name", + "set 'name' on each entry under connections:", + ) + } + + // Rung 1: an existing connection with this name is used as-is. + if _, ok := existing[decl.Name]; ok { + return connActionUseExisting, decl, nil + } + + // Rung 3: auto-fill the target from provisioning outputs when absent. + resolved := decl + if strings.TrimSpace(resolved.Target) == "" { + if t := targetFromEnv(decl.Name, env); t != "" { + resolved.Target = t + } + } + + // Rung 2: with a known target, create the connection (Entra default). + if strings.TrimSpace(resolved.Target) != "" { + return connActionCreate, resolved, nil + } + + // Rung 4: no target — provision if opted in, else fail fast. + if decl.Provision { + return connActionProvision, resolved, nil + } + return connActionFailFast, resolved, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf( + "connection %q has no existing connection and no resolvable target", decl.Name, + ), + "set connections["+decl.Name+"].target, or set provision: true to create the backing resource", + ) +} + +// connectionResolver performs the side effects of the ladder: listing existing +// connections, creating missing ones, and assigning roles. The seam keeps the +// connection node unit-testable without a live endpoint. +type connectionResolver interface { + // Existing returns the names of connections already present in the project, + // mapped to their ids. + Existing(ctx context.Context) (map[string]string, error) + // Create creates a connection from the (possibly target-filled) declaration + // and returns its id. + Create(ctx context.Context, decl agent_yaml.PromptConnection) (id string, err error) + // AssignRole assigns roleID to the agent/project identity on the connection's + // backing resource. Implementations may no-op with a warning when the + // principal or scope is not yet known. + AssignRole(ctx context.Context, decl agent_yaml.PromptConnection, roleID, roleName string) error +} + +// connectionsNode builds the connection + rbac graph node. It resolves every +// declared connection through the ladder, creates the missing ones, and assigns +// each referenced tool's required role. Returns nil when nothing is declared. +func connectionsNode( + g *promptGraph, + newResolver func() (connectionResolver, error), +) *promptNode { + decls := g.managed.Connections + if len(decls) == 0 { + return nil + } + return &promptNode{ + Kind: nodeConnection, + ID: "connections", + Validate: func() error { + for _, d := range decls { + if strings.TrimSpace(d.Name) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "a declared connection is missing a name", + "set 'name' on each entry under connections:", + ) + } + } + return nil + }, + Resolve: func(ctx context.Context) error { + resolver, err := newResolver() + if err != nil { + return err + } + existing, err := resolver.Existing(ctx) + if err != nil { + return err + } + + for _, decl := range decls { + action, resolved, decideErr := resolveConnectionAction(decl, existing, g.env) + if decideErr != nil { + return decideErr + } + switch action { + case connActionUseExisting: + // Nothing to create. + case connActionCreate, connActionProvision: + id, createErr := resolver.Create(ctx, resolved) + if createErr != nil { + return fmt.Errorf("creating connection %q: %w", resolved.Name, createErr) + } + existing[resolved.Name] = id + case connActionFailFast: + // resolveConnectionAction already returned an error for this + // case; defensively guard here. + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("connection %q could not be resolved", resolved.Name), + "declare a target or set provision: true", + ) + } + } + + // Assign roles for tools that reference a connection needing one. + return assignConnectionRoles(ctx, resolver, g.managed) + }, + } +} + +// assignConnectionRoles walks the agent's tools, and for each tool that both +// requires a role and references a declared connection, assigns that role. +func assignConnectionRoles( + ctx context.Context, + resolver connectionResolver, + managed *agent_yaml.ManagedAgent, +) error { + byName := map[string]agent_yaml.PromptConnection{} + for _, c := range managed.Connections { + byName[c.Name] = c + } + + for _, raw := range managed.Tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + toolType := fmt.Sprintf("%v", tool["type"]) + roleID, roleName, need := requiredRoleForTool(toolType) + if !need { + continue + } + connName := toolConnectionName(tool) + if connName == "" { + continue + } + decl, ok := byName[connName] + if !ok { + continue + } + if err := resolver.AssignRole(ctx, decl, roleID, roleName); err != nil { + return fmt.Errorf("assigning %s for connection %q: %w", roleName, connName, err) + } + } + return nil +} + +// toolConnectionName extracts the connection name a tool references, tolerating +// both a top-level `connection` string and a nested `project_connection_id`. +func toolConnectionName(tool map[string]any) string { + if v, ok := tool["connection"]; ok { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + return s + } + } + if v, ok := tool["project_connection_id"]; ok { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + return s + } + } + return "" +} + +// foundryConnectionResolver is the live connectionResolver backed by the +// Foundry project connections data-plane. +type foundryConnectionResolver struct { + client *azure.FoundryProjectsClient +} + +// Existing lists the project's connections as a name -> id map. +func (r *foundryConnectionResolver) Existing(ctx context.Context) (map[string]string, error) { + conns, err := r.client.GetAllConnections(ctx) + if err != nil { + return nil, fmt.Errorf("listing project connections: %w", err) + } + out := make(map[string]string, len(conns)) + for _, c := range conns { + out[c.Name] = c.ID + } + return out, nil +} + +// Create creates a connection from the declaration, defaulting to Entra auth. +func (r *foundryConnectionResolver) Create( + ctx context.Context, decl agent_yaml.PromptConnection, +) (string, error) { + created, err := r.client.CreateConnection(ctx, decl.Name, &azure.CreateConnectionRequest{ + Category: decl.Category, + Target: decl.Target, + AuthType: decl.AuthType, // empty defaults to AAD in the client + Credentials: decl.Credentials, + Metadata: decl.Metadata, + }) + if err != nil { + return "", err + } + return created.ID, nil +} + +// AssignRole is best-effort at deploy time. The agent's instance identity is +// only known after the agent version is created, and a data-plane connection +// does not expose its backing resource's ARM scope, so a fully automatic +// assignment is not possible here. Rather than fail the deploy, surface the +// exact manual command so the operator can grant access, consistent with the +// hosted-agent RBAC UX. +func (r *foundryConnectionResolver) AssignRole( + _ context.Context, decl agent_yaml.PromptConnection, _ string, roleName string, +) error { + fmt.Printf("%s\n", output.WithWarningFormat( + "Connection %q needs the %q role on its backing resource (%s).\n"+ + " Automatic assignment is not available at deploy time for prompt agents.\n"+ + " Grant it to the agent identity once the agent is created, e.g.:\n"+ + " az role assignment create --assignee "+ + "--role %q --scope ", + decl.Name, roleName, decl.Target, roleName, + )) + return nil +} + +// newFoundryConnectionResolver builds the live resolver from prompt settings by +// parsing the account/project from the project endpoint. +func newFoundryConnectionResolver(settings *PromptAgentSettings) (connectionResolver, error) { + if settings == nil || strings.TrimSpace(settings.ProjectEndpoint) == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "a Foundry project endpoint is required to resolve connections", + "run `azd up` to provision a Foundry project, or remove the connections: block", + ) + } + account, project, err := parseAccountProject(settings.ProjectEndpoint) + if err != nil { + return nil, err + } + client, err := azure.NewFoundryProjectsClient(account, project, promptCredential()) + if err != nil { + return nil, err + } + return &foundryConnectionResolver{client: client}, nil +} + +// parseAccountProject extracts the account and project names from a Foundry +// project endpoint of the form +// https://.services.ai.azure.com/api/projects/. +func parseAccountProject(endpoint string) (account, project string, err error) { + u, parseErr := url.Parse(strings.TrimSpace(endpoint)) + if parseErr != nil || u.Host == "" { + return "", "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("could not parse project endpoint %q", endpoint), + "ensure the project endpoint looks like https://.services.ai.azure.com/api/projects/", + ) + } + account = strings.SplitN(u.Host, ".", 2)[0] + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + for i := 0; i+1 < len(parts); i++ { + if parts[i] == "projects" { + project = parts[i+1] + break + } + } + if account == "" || project == "" { + return "", "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("project endpoint %q is missing an account or project segment", endpoint), + "ensure the project endpoint includes /api/projects/", + ) + } + return account, project, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go new file mode 100644 index 00000000000..4816aa5a0c8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" +) + +func TestResolveConnectionAction_Rung1_ExistingByName(t *testing.T) { + existing := map[string]string{"aisearch-conn": "id-1"} + decl := agent_yaml.PromptConnection{Name: "aisearch-conn", Category: "CognitiveSearch"} + + action, _, err := resolveConnectionAction(decl, existing, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if action != connActionUseExisting { + t.Errorf("action: got %v, want use-existing", action) + } +} + +func TestResolveConnectionAction_Rung2_CreateWithTarget(t *testing.T) { + decl := agent_yaml.PromptConnection{ + Name: "aisearch-conn", + Category: "CognitiveSearch", + Target: "https://s.search.windows.net", + AuthType: "Entra", + } + action, resolved, err := resolveConnectionAction(decl, map[string]string{}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if action != connActionCreate { + t.Errorf("action: got %v, want create", action) + } + if resolved.Target != decl.Target { + t.Errorf("target: got %q", resolved.Target) + } +} + +func TestResolveConnectionAction_Rung3_AutoFillTarget(t *testing.T) { + decl := agent_yaml.PromptConnection{Name: "aisearch-conn", Category: "CognitiveSearch"} + env := map[string]string{ + "AI_PROJECT_CONNECTIONS": "other=https://x; aisearch-conn=https://filled.search.windows.net", + } + action, resolved, err := resolveConnectionAction(decl, map[string]string{}, env) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if action != connActionCreate { + t.Errorf("action: got %v, want create", action) + } + if resolved.Target != "https://filled.search.windows.net" { + t.Errorf("auto-filled target: got %q", resolved.Target) + } +} + +func TestResolveConnectionAction_Rung4_ProvisionOptIn(t *testing.T) { + decl := agent_yaml.PromptConnection{Name: "search-conn", Category: "CognitiveSearch", Provision: true} + action, _, err := resolveConnectionAction(decl, map[string]string{}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if action != connActionProvision { + t.Errorf("action: got %v, want provision", action) + } +} + +func TestResolveConnectionAction_Rung4_FailFastNoOptIn(t *testing.T) { + decl := agent_yaml.PromptConnection{Name: "search-conn", Category: "CognitiveSearch"} + action, _, err := resolveConnectionAction(decl, map[string]string{}, nil) + if err == nil { + t.Fatal("expected fail-fast error") + } + if action != connActionFailFast { + t.Errorf("action: got %v, want fail-fast", action) + } +} + +func TestRequiredRoleForTool(t *testing.T) { + roleID, roleName, ok := requiredRoleForTool("azure_ai_search") + if !ok || roleID == "" || roleName != "Search Index Data Reader" { + t.Errorf("azure_ai_search: got %q, %q, %v", roleID, roleName, ok) + } + if _, _, ok := requiredRoleForTool("code_interpreter"); ok { + t.Error("code_interpreter should need no role") + } +} + +func TestTargetFromEnv(t *testing.T) { + env := map[string]string{ + "AI_PROJECT_DEPENDENT_RESOURCES": "foo=https://foo; conn=https://target", + } + if got := targetFromEnv("conn", env); got != "https://target" { + t.Errorf("target: got %q", got) + } + if got := targetFromEnv("missing", env); got != "" { + t.Errorf("missing target: got %q", got) + } +} + +// fakeConnectionResolver records calls and simulates an existing set. +type fakeConnectionResolver struct { + existing map[string]string + created []agent_yaml.PromptConnection + roleAssigned []string +} + +func (r *fakeConnectionResolver) Existing(context.Context) (map[string]string, error) { + if r.existing == nil { + r.existing = map[string]string{} + } + return r.existing, nil +} + +func (r *fakeConnectionResolver) Create( + _ context.Context, decl agent_yaml.PromptConnection, +) (string, error) { + r.created = append(r.created, decl) + return "new-id", nil +} + +func (r *fakeConnectionResolver) AssignRole( + _ context.Context, _ agent_yaml.PromptConnection, _, roleName string, +) error { + r.roleAssigned = append(r.roleAssigned, roleName) + return nil +} + +func TestConnectionsNode_CreatesMissingAndAssignsRole(t *testing.T) { + managed := &agent_yaml.ManagedAgent{ + Model: "m", + Instructions: "i", + Connections: []agent_yaml.PromptConnection{ + {Name: "aisearch-conn", Category: "CognitiveSearch", Target: "https://s", AuthType: "Entra"}, + }, + Tools: []any{ + map[string]any{"type": "azure_ai_search", "connection": "aisearch-conn"}, + }, + } + managed.Name = "agent" + g := &promptGraph{managed: managed, env: map[string]string{}, bindings: map[string]any{}} + fake := &fakeConnectionResolver{} + + node := connectionsNode(g, func() (connectionResolver, error) { return fake, nil }) + if node == nil { + t.Fatal("expected a connections node") + } + if err := node.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if len(fake.created) != 1 || fake.created[0].Name != "aisearch-conn" { + t.Errorf("created: got %+v", fake.created) + } + if len(fake.roleAssigned) != 1 || fake.roleAssigned[0] != "Search Index Data Reader" { + t.Errorf("roles: got %+v", fake.roleAssigned) + } +} + +func TestConnectionsNode_UsesExistingNoCreate(t *testing.T) { + managed := &agent_yaml.ManagedAgent{ + Model: "m", + Instructions: "i", + Connections: []agent_yaml.PromptConnection{ + {Name: "existing-conn", Category: "CognitiveSearch"}, + }, + } + managed.Name = "agent" + g := &promptGraph{managed: managed, env: map[string]string{}, bindings: map[string]any{}} + fake := &fakeConnectionResolver{existing: map[string]string{"existing-conn": "id-x"}} + + node := connectionsNode(g, func() (connectionResolver, error) { return fake, nil }) + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + if len(fake.created) != 0 { + t.Errorf("expected no create for existing connection, got %+v", fake.created) + } +} + +func TestConnectionsNode_NoneReturnsNil(t *testing.T) { + g := &promptGraph{managed: &agent_yaml.ManagedAgent{}, bindings: map[string]any{}} + node := connectionsNode(g, func() (connectionResolver, error) { return nil, nil }) + if node != nil { + t.Fatal("expected nil node when no connections declared") + } +} + +func TestParseAccountProject(t *testing.T) { + account, project, err := parseAccountProject( + "https://myacct.services.ai.azure.com/api/projects/myproj", + ) + if err != nil { + t.Fatalf("parseAccountProject: %v", err) + } + if account != "myacct" || project != "myproj" { + t.Errorf("got account=%q project=%q", account, project) + } + + if _, _, err := parseAccountProject("not-a-url"); err == nil { + t.Error("expected error for invalid endpoint") + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go new file mode 100644 index 00000000000..4f40f461039 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" +) + +// writeAgentYAML writes an agent.yaml (and optional instructions.md) into a temp +// dir and returns a provider pointed at it. +func writeAgentYAML(t *testing.T, agentYAML string, instructionsMD *string) *AgentServiceTargetProvider { + t.Helper() + dir := t.TempDir() + agentPath := filepath.Join(dir, "agent.yaml") + if err := os.WriteFile(agentPath, []byte(agentYAML), 0o600); err != nil { + t.Fatalf("write agent.yaml: %v", err) + } + if instructionsMD != nil { + if err := os.WriteFile(filepath.Join(dir, "instructions.md"), []byte(*instructionsMD), 0o600); err != nil { + t.Fatalf("write instructions.md: %v", err) + } + } + return &AgentServiceTargetProvider{agentDefinitionPath: agentPath} +} + +// TestLoadPromptDef_InstructionsFileFallback verifies a sibling instructions.md +// supplies the agent's instructions when none are declared inline. +func TestLoadPromptDef_InstructionsFileFallback(t *testing.T) { + md := "You are a careful assistant.\nAnswer concisely." + p := writeAgentYAML(t, ` +kind: managed +name: file-instr +model: gpt-4.1-mini +`, &md) + + managed, err := p.loadPromptAgentDefinition() + if err != nil { + t.Fatalf("loadPromptAgentDefinition: %v", err) + } + if managed.Instructions != md { + t.Errorf("instructions: got %q, want %q", managed.Instructions, md) + } +} + +// TestLoadPromptDef_InlineWinsOverFile verifies inline instructions take +// precedence over a sibling instructions.md. +func TestLoadPromptDef_InlineWinsOverFile(t *testing.T) { + md := "FROM FILE" + p := writeAgentYAML(t, ` +kind: managed +name: inline-wins +model: gpt-4.1-mini +instructions: FROM INLINE +`, &md) + + managed, err := p.loadPromptAgentDefinition() + if err != nil { + t.Fatalf("loadPromptAgentDefinition: %v", err) + } + if managed.Instructions != "FROM INLINE" { + t.Errorf("instructions: got %q, want inline value", managed.Instructions) + } +} + +// TestLoadPromptDef_NoInstructionsAnywhere confirms neither inline nor file +// instructions leaves the field empty (graph validation reports the error). +func TestLoadPromptDef_NoInstructionsAnywhere(t *testing.T) { + p := writeAgentYAML(t, ` +kind: managed +name: no-instr +model: gpt-4.1-mini +`, nil) + + managed, err := p.loadPromptAgentDefinition() + if err != nil { + t.Fatalf("loadPromptAgentDefinition: %v", err) + } + if strings.TrimSpace(managed.Instructions) != "" { + t.Errorf("instructions: got %q, want empty", managed.Instructions) + } +} + +// TestLoadPromptDef_RejectsContainerFields verifies container-only fields are +// rejected for a prompt (kind: managed) agent. +func TestLoadPromptDef_RejectsContainerFields(t *testing.T) { + cases := []string{"image", "protocols", "code_configuration", "agent_endpoint"} + for _, field := range cases { + t.Run(field, func(t *testing.T) { + p := writeAgentYAML(t, ` +kind: managed +name: bad +model: gpt-4.1-mini +instructions: ok +`+field+`: something +`, nil) + + _, err := p.loadPromptAgentDefinition() + if err == nil { + t.Fatalf("expected error for container-only field %q", field) + } + if !strings.Contains(err.Error(), field) { + t.Errorf("error should name the field %q: %v", field, err) + } + }) + } +} + +// TestResolvePromptAgentGraph_ValidatesModelAndInstructions verifies the graph +// validation pass surfaces missing model/instructions before any resolve, and +// succeeds for a complete definition. +func TestResolvePromptAgentGraph_ValidatesModelAndInstructions(t *testing.T) { + p := &AgentServiceTargetProvider{} + + // Missing model → error. + missingModel := &agent_yaml.ManagedAgent{Instructions: "ok"} + missingModel.Name = "x" + if err := p.resolvePromptAgentGraph(t.Context(), missingModel, nil, nil, nil); err == nil { + t.Error("expected error when model is empty") + } + + // Missing instructions → error. + missingInstr := &agent_yaml.ManagedAgent{Model: "gpt-4.1-mini"} + missingInstr.Name = "x" + if err := p.resolvePromptAgentGraph(t.Context(), missingInstr, nil, nil, nil); err == nil { + t.Error("expected error when instructions are empty") + } + + // Complete → no error. + complete := &agent_yaml.ManagedAgent{Model: "gpt-4.1-mini", Instructions: "ok"} + complete.Name = "x" + if err := p.resolvePromptAgentGraph(t.Context(), complete, nil, nil, nil); err != nil { + t.Errorf("unexpected error for complete definition: %v", err) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment.go new file mode 100644 index 00000000000..5bb88159b0d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment.go @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "os" + "strings" + + "azureaiagent/internal/exterrors" +) + +// deploymentResolver checks for and creates a model deployment. The seam keeps +// the deployment node unit-testable without touching Azure. +type deploymentResolver interface { + // Exists reports whether a deployment for modelName is present. + Exists(ctx context.Context, modelName string) (bool, error) + // Create creates the deployment for modelName. It must be idempotent. + Create(ctx context.Context, modelName string) error +} + +// deploymentNode builds the model-deployment graph node. It validates that a +// model is declared and, at resolve time, creates the deployment if missing. +// Returns nil when no model is declared (the agent node reports that error). +func deploymentNode( + g *promptGraph, + newResolver func() (deploymentResolver, error), +) *promptNode { + model := strings.TrimSpace(g.managed.Model) + if model == "" { + return nil + } + return &promptNode{ + Kind: nodeDeployment, + ID: model, + Validate: func() error { + // The model name must be a simple deployment identifier. + if strings.ContainsAny(model, " /\\") { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("model %q is not a valid deployment name", model), + "set 'model' to a model deployment name (e.g. gpt-4.1-mini)", + ) + } + return nil + }, + Resolve: func(ctx context.Context) error { + resolver, err := newResolver() + if err != nil { + return err + } + exists, err := resolver.Exists(ctx, model) + if err != nil { + return err + } + if exists { + return nil + } + if err := resolver.Create(ctx, model); err != nil { + return fmt.Errorf("creating model deployment %q: %w", model, err) + } + return nil + }, + } +} + +// provisionedDeploymentResolver is the live deploymentResolver. Model +// deployments for prompt agents are provisioned by azd infra (recorded at init +// and applied during `azd provision`), so at deploy time the deployment is +// assumed present. This resolver therefore treats every model as existing and +// never issues a data-plane create, but keeps the seam so the graph can enforce +// the create-if-missing contract in tests and future live wiring. +type provisionedDeploymentResolver struct{} + +func (provisionedDeploymentResolver) Exists(context.Context, string) (bool, error) { + return true, nil +} + +func (provisionedDeploymentResolver) Create(_ context.Context, modelName string) error { + // Should not be reached given Exists always returns true; guard defensively + // with an actionable message rather than a silent no-op. + fmt.Fprintf(os.Stderr, + "Model deployment %q was not found. Provision it with `azd provision` "+ + "(deployments are declared in azure.yaml).\n", modelName) + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment_test.go new file mode 100644 index 00000000000..8cbd557d951 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment_test.go @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// fakeDeploymentResolver records existence checks and creations. +type fakeDeploymentResolver struct { + exists bool + creates int + checks int +} + +func (r *fakeDeploymentResolver) Exists(context.Context, string) (bool, error) { + r.checks++ + return r.exists, nil +} + +func (r *fakeDeploymentResolver) Create(context.Context, string) error { + r.creates++ + return nil +} + +func TestDeploymentNode_CreatesWhenMissing(t *testing.T) { + managed := &agent_yaml.ManagedAgent{Model: "gpt-4.1-mini", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeDeploymentResolver{exists: false} + + node := deploymentNode(g, func() (deploymentResolver, error) { return fake, nil }) + if node == nil { + t.Fatal("expected a deployment node") + } + if err := node.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + if fake.checks != 1 || fake.creates != 1 { + t.Errorf("expected 1 check + 1 create, got %d/%d", fake.checks, fake.creates) + } +} + +func TestDeploymentNode_SkipsCreateWhenExists(t *testing.T) { + managed := &agent_yaml.ManagedAgent{Model: "gpt-4.1-mini", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeDeploymentResolver{exists: true} + + node := deploymentNode(g, func() (deploymentResolver, error) { return fake, nil }) + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + if fake.creates != 0 { + t.Errorf("expected no create when deployment exists, got %d", fake.creates) + } +} + +func TestDeploymentNode_ValidateRejectsBadModelName(t *testing.T) { + managed := &agent_yaml.ManagedAgent{Model: "not a/valid name", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + node := deploymentNode(g, func() (deploymentResolver, error) { + return &fakeDeploymentResolver{}, nil + }) + if err := node.Validate(); err == nil { + t.Error("expected validation error for invalid model name") + } +} + +// TestGraphResolve_ValidatesAllBeforeAnyMutation proves the graph runs every +// node's Validate before any Resolve, so a validation failure never leaves a +// half-wired agent (no Resolve side effects occur). +func TestGraphResolve_ValidatesAllBeforeAnyMutation(t *testing.T) { + resolved := 0 + g := &promptGraph{ + managed: &agent_yaml.ManagedAgent{}, + bindings: map[string]any{}, + nodes: []promptNode{ + { + Kind: nodeFileStore, + Validate: func() error { return nil }, + Resolve: func(context.Context) error { + resolved++ + return nil + }, + }, + { + Kind: nodeConnection, + Validate: func() error { return errors.New("bad connection") }, + Resolve: func(context.Context) error { + resolved++ + return nil + }, + }, + }, + } + + err := g.resolve(context.Background(), azdext.ProgressReporter(nil)) + if err == nil { + t.Fatal("expected validation error") + } + if resolved != 0 { + t.Errorf("no Resolve should run when validation fails; ran %d", resolved) + } +} + +// TestGraphResolve_ResolvesInOrderWhenValid confirms all nodes resolve when +// validation passes. +func TestGraphResolve_ResolvesInOrderWhenValid(t *testing.T) { + var order []promptNodeKind + g := &promptGraph{ + managed: &agent_yaml.ManagedAgent{}, + bindings: map[string]any{}, + nodes: []promptNode{ + { + Kind: nodeDeployment, + Validate: func() error { return nil }, + Resolve: func(context.Context) error { + order = append(order, nodeDeployment) + return nil + }, + }, + { + Kind: nodeAgent, + Validate: func() error { return nil }, + Resolve: func(context.Context) error { + order = append(order, nodeAgent) + return nil + }, + }, + }, + } + + if err := g.resolve(context.Background(), azdext.ProgressReporter(nil)); err != nil { + t.Fatalf("resolve: %v", err) + } + if len(order) != 2 || order[0] != nodeDeployment || order[1] != nodeAgent { + t.Errorf("resolve order: got %v", order) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go new file mode 100644 index 00000000000..c3e86dc6daf --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go @@ -0,0 +1,253 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" +) + +// promptFilesDirName is the conventional folder whose documents are uploaded to +// a vector store backing the agent's file_search tool. +const promptFilesDirName = "files" + +// vectorStoreBindingKey is the graph binding under which the resolved vector +// store id is published for later nodes / observability. +const vectorStoreBindingKey = "vector_store_id" + +// fileEntry is one document contributed to the vector store, with its content +// hash used for dedupe across re-deploys. +type fileEntry struct { + Name string // base file name + Path string // absolute path on disk + Hash string // sha256 of the content, hex-encoded + Content []byte +} + +// vectorStoreBuilder uploads files and (re)builds a vector store, returning the +// store id. Implementations are idempotent: unchanged files (matched by hash) +// are skipped and an existing store is reused/updated rather than recreated. +// The seam keeps the graph node unit-testable without a live endpoint. +type vectorStoreBuilder interface { + EnsureVectorStore( + ctx context.Context, name, reuseStoreID string, files []fileEntry, + ) (storeID string, err error) +} + +// scanFilesDir returns the documents under /files, sorted by name. +// Dotfiles and subdirectories are ignored. A missing or empty folder returns +// (nil, nil) so the caller contributes no file_search tool. +func scanFilesDir(agentDir string) ([]fileEntry, error) { + if strings.TrimSpace(agentDir) == "" { + return nil, nil + } + dir := filepath.Join(agentDir, promptFilesDirName) + + f, err := os.Open(dir) //nolint:gosec // agentDir derives from the resolved agent.yaml path + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("opening files directory %q: %w", dir, err) + } + names, err := f.Readdirnames(-1) + _ = f.Close() + if err != nil { + return nil, fmt.Errorf("reading files directory %q: %w", dir, err) + } + + var entries []fileEntry + for _, name := range names { + if strings.HasPrefix(name, ".") { + continue + } + full := filepath.Join(dir, name) + info, statErr := os.Stat(full) + if statErr != nil { + return nil, fmt.Errorf("stat %q: %w", full, statErr) + } + if info.IsDir() { + continue + } + content, readErr := os.ReadFile(full) //nolint:gosec // path derived from the agent's files/ folder + if readErr != nil { + return nil, fmt.Errorf("reading %q: %w", full, readErr) + } + sum := sha256.Sum256(content) + entries = append(entries, fileEntry{ + Name: name, + Path: full, + Hash: hex.EncodeToString(sum[:]), + Content: content, + }) + } + + slices.SortFunc(entries, func(a, b fileEntry) int { + return strings.Compare(a.Name, b.Name) + }) + return entries, nil +} + +// injectFileSearchTool ensures the agent's tools include a file_search tool +// wired to storeID. If a file_search tool already exists, storeID is merged +// into its vector_store_ids (deduped) rather than adding a second tool. The +// managed definition is mutated in place. +func injectFileSearchTool(managed *agent_yaml.ManagedAgent, storeID string) { + if managed == nil || strings.TrimSpace(storeID) == "" { + return + } + + for i, raw := range managed.Tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if fmt.Sprintf("%v", tool["type"]) != "file_search" { + continue + } + ids := toStringSlice(tool["vector_store_ids"]) + if !slices.Contains(ids, storeID) { + ids = append(ids, storeID) + } + tool["vector_store_ids"] = ids + managed.Tools[i] = tool + return + } + + managed.Tools = append(managed.Tools, map[string]any{ + "type": "file_search", + "vector_store_ids": []string{storeID}, + }) +} + +// toStringSlice coerces a decoded YAML/JSON value into a []string, tolerating +// []any (as produced by the YAML decoder) and []string. +func toStringSlice(v any) []string { + switch t := v.(type) { + case []string: + return slices.Clone(t) + case []any: + out := make([]string, 0, len(t)) + for _, e := range t { + out = append(out, fmt.Sprintf("%v", e)) + } + return out + default: + return nil + } +} + +// fileStoreNode builds the file_store graph node for the given files. It uploads +// the documents (via the builder), publishes the resolved store id into the +// graph bindings, and injects/merges the file_search tool. Returns nil when +// there are no files (the caller then registers no node). +func fileStoreNode( + g *promptGraph, + files []fileEntry, + newBuilder func() (vectorStoreBuilder, error), +) *promptNode { + if len(files) == 0 { + return nil + } + return &promptNode{ + Kind: nodeFileStore, + ID: promptFilesDirName, + Validate: func() error { + for _, f := range files { + if len(f.Content) == 0 { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("file %q in the files/ folder is empty", f.Name), + "remove empty files or add content before deploying", + ) + } + } + return nil + }, + Resolve: func(ctx context.Context) error { + builder, err := newBuilder() + if err != nil { + return err + } + reuse, _ := g.bindings[vectorStoreBindingKey].(string) + storeID, err := builder.EnsureVectorStore(ctx, g.managed.Name, reuse, files) + if err != nil { + return err + } + g.bindings[vectorStoreBindingKey] = storeID + injectFileSearchTool(g.managed, storeID) + return nil + }, + } +} + +// foundryVectorStoreBuilder is the live vectorStoreBuilder backed by the +// Foundry Files + Vector Stores endpoints. It dedupes unchanged files by hash +// and reuses an existing store id when one is supplied. +type foundryVectorStoreBuilder struct { + client *azure.FoundryFilesClient + // uploaded maps content hash -> file id within this deploy, so a file that + // appears more than once is uploaded only once. + uploaded map[string]string +} + +// EnsureVectorStore uploads any not-yet-uploaded files and creates a vector +// store from the resulting file ids. When reuseStoreID is set it is returned +// as-is after ensuring uploads (add-only update); otherwise a new store is +// created and its id returned. +func (b *foundryVectorStoreBuilder) EnsureVectorStore( + ctx context.Context, name, reuseStoreID string, files []fileEntry, +) (string, error) { + if b.uploaded == nil { + b.uploaded = map[string]string{} + } + fileIDs := make([]string, 0, len(files)) + for _, f := range files { + if id, ok := b.uploaded[f.Hash]; ok { + fileIDs = append(fileIDs, id) + continue + } + obj, err := b.client.UploadFile(ctx, f.Name, f.Content, "assistants") + if err != nil { + return "", fmt.Errorf("uploading %q: %w", f.Name, err) + } + b.uploaded[f.Hash] = obj.Id + fileIDs = append(fileIDs, obj.Id) + } + + if strings.TrimSpace(reuseStoreID) != "" { + return reuseStoreID, nil + } + + store, err := b.client.CreateVectorStore(ctx, name, fileIDs) + if err != nil { + return "", fmt.Errorf("creating vector store: %w", err) + } + return store.Id, nil +} + +// newFoundryVectorStoreBuilder constructs the live builder from prompt settings. +// It requires a resolved project endpoint (data-plane) to reach the Files API. +func newFoundryVectorStoreBuilder(settings *PromptAgentSettings) (vectorStoreBuilder, error) { + if settings == nil || strings.TrimSpace(settings.ProjectEndpoint) == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "a Foundry project endpoint is required to upload files for file_search", + "run `azd up` to provision a Foundry project, or remove the files/ folder", + ) + } + return &foundryVectorStoreBuilder{ + client: azure.NewFoundryFilesClient(settings.ProjectEndpoint, promptCredential()), + }, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go new file mode 100644 index 00000000000..1219b195d29 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "os" + "path/filepath" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" +) + +// fakeVectorStoreBuilder records the files it was asked to build and returns a +// fixed store id, so node behavior can be asserted without a live endpoint. +type fakeVectorStoreBuilder struct { + storeID string + calls int + lastFiles []fileEntry + lastReuse string +} + +func (b *fakeVectorStoreBuilder) EnsureVectorStore( + _ context.Context, _ string, reuseStoreID string, files []fileEntry, +) (string, error) { + b.calls++ + b.lastFiles = files + b.lastReuse = reuseStoreID + if b.storeID == "" { + b.storeID = "vs-fake" + } + return b.storeID, nil +} + +func writeFilesDir(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + if files == nil { + return dir + } + filesDir := filepath.Join(dir, "files") + if err := os.MkdirAll(filesDir, 0o750); err != nil { + t.Fatalf("mkdir files: %v", err) + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(filesDir, name), []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + return dir +} + +func TestScanFilesDir_Empty(t *testing.T) { + // Absent files/ folder. + dir := writeFilesDir(t, nil) + entries, err := scanFilesDir(dir) + if err != nil { + t.Fatalf("scanFilesDir: %v", err) + } + if entries != nil { + t.Errorf("expected nil entries for missing files/, got %d", len(entries)) + } +} + +func TestScanFilesDir_IgnoresDotfiles(t *testing.T) { + dir := writeFilesDir(t, map[string]string{ + ".DS_Store": "junk", + "faq.md": "content", + }) + entries, err := scanFilesDir(dir) + if err != nil { + t.Fatalf("scanFilesDir: %v", err) + } + if len(entries) != 1 || entries[0].Name != "faq.md" { + t.Fatalf("expected only faq.md, got %+v", entries) + } + if entries[0].Hash == "" { + t.Error("expected a content hash") + } +} + +func TestScanFilesDir_SortedByName(t *testing.T) { + dir := writeFilesDir(t, map[string]string{ + "b.md": "b", + "a.md": "a", + "c.md": "c", + }) + entries, err := scanFilesDir(dir) + if err != nil { + t.Fatalf("scanFilesDir: %v", err) + } + got := []string{entries[0].Name, entries[1].Name, entries[2].Name} + want := []string{"a.md", "b.md", "c.md"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("sort: got %v, want %v", got, want) + } + } +} + +func TestInjectFileSearchTool_AddsWhenAbsent(t *testing.T) { + managed := &agent_yaml.ManagedAgent{} + injectFileSearchTool(managed, "vs-1") + + if len(managed.Tools) != 1 { + t.Fatalf("tools: got %d, want 1", len(managed.Tools)) + } + tool := managed.Tools[0].(map[string]any) + if tool["type"] != "file_search" { + t.Errorf("type: got %v", tool["type"]) + } + ids := toStringSlice(tool["vector_store_ids"]) + if len(ids) != 1 || ids[0] != "vs-1" { + t.Errorf("vector_store_ids: got %v", ids) + } +} + +func TestInjectFileSearchTool_MergesExisting(t *testing.T) { + managed := &agent_yaml.ManagedAgent{ + Tools: []any{ + map[string]any{ + "type": "file_search", + "vector_store_ids": []any{"vs-existing"}, + }, + }, + } + injectFileSearchTool(managed, "vs-new") + + if len(managed.Tools) != 1 { + t.Fatalf("tools: got %d, want 1 (merged, not duplicated)", len(managed.Tools)) + } + tool := managed.Tools[0].(map[string]any) + ids := toStringSlice(tool["vector_store_ids"]) + if len(ids) != 2 || ids[0] != "vs-existing" || ids[1] != "vs-new" { + t.Errorf("merged ids: got %v, want [vs-existing vs-new]", ids) + } +} + +func TestInjectFileSearchTool_NoDuplicateID(t *testing.T) { + managed := &agent_yaml.ManagedAgent{ + Tools: []any{ + map[string]any{ + "type": "file_search", + "vector_store_ids": []any{"vs-1"}, + }, + }, + } + injectFileSearchTool(managed, "vs-1") + + tool := managed.Tools[0].(map[string]any) + ids := toStringSlice(tool["vector_store_ids"]) + if len(ids) != 1 { + t.Errorf("expected no duplicate, got %v", ids) + } +} + +func TestFileStoreNode_NoFilesNoNode(t *testing.T) { + g := &promptGraph{managed: &agent_yaml.ManagedAgent{}, bindings: map[string]any{}} + node := fileStoreNode(g, nil, func() (vectorStoreBuilder, error) { return nil, nil }) + if node != nil { + t.Fatal("expected no node when there are no files") + } +} + +func TestFileStoreNode_InjectsFileSearch(t *testing.T) { + managed := &agent_yaml.ManagedAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeVectorStoreBuilder{storeID: "vs-42"} + + files := []fileEntry{{Name: "faq.md", Hash: "h", Content: []byte("x")}} + node := fileStoreNode(g, files, func() (vectorStoreBuilder, error) { return fake, nil }) + if node == nil { + t.Fatal("expected a file_store node") + } + if err := node.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if fake.calls != 1 { + t.Errorf("builder calls: got %d, want 1", fake.calls) + } + if g.bindings[vectorStoreBindingKey] != "vs-42" { + t.Errorf("binding: got %v", g.bindings[vectorStoreBindingKey]) + } + if len(managed.Tools) != 1 { + t.Fatalf("tools: got %d, want 1", len(managed.Tools)) + } + tool := managed.Tools[0].(map[string]any) + ids := toStringSlice(tool["vector_store_ids"]) + if len(ids) != 1 || ids[0] != "vs-42" { + t.Errorf("vector_store_ids: got %v", ids) + } +} + +func TestFileStoreNode_ValidateRejectsEmptyFile(t *testing.T) { + g := &promptGraph{managed: &agent_yaml.ManagedAgent{}, bindings: map[string]any{}} + files := []fileEntry{{Name: "empty.md", Hash: "h", Content: []byte{}}} + node := fileStoreNode(g, files, func() (vectorStoreBuilder, error) { return nil, nil }) + if node == nil { + t.Fatal("expected a node") + } + if err := node.Validate(); err == nil { + t.Error("expected validation error for empty file") + } +} + +func TestFoundryVectorStoreBuilder_DedupesByHash(t *testing.T) { + b := &foundryVectorStoreBuilder{uploaded: map[string]string{"h1": "file-1"}} + // Two entries with the same hash h1 should not trigger any upload; since + // the client is nil, an upload attempt would panic — proving dedupe. + files := []fileEntry{ + {Name: "a.md", Hash: "h1", Content: []byte("a")}, + {Name: "b.md", Hash: "h1", Content: []byte("a")}, + } + storeID, err := b.EnsureVectorStore(context.Background(), "agent", "vs-existing", files) + if err != nil { + t.Fatalf("EnsureVectorStore: %v", err) + } + if storeID != "vs-existing" { + t.Errorf("store id: got %q, want reused vs-existing", storeID) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go new file mode 100644 index 00000000000..d5e08ddee3c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// promptNodeKind enumerates the resolvable dependency kinds in a prompt-agent +// deploy graph. Additional kinds (file_store, skill, toolbox, connection, rbac, +// deployment) are registered by later stages of the deploy engine. +type promptNodeKind string + +const ( + nodeAgent promptNodeKind = "agent" + nodeDeployment promptNodeKind = "deployment" + nodeConnection promptNodeKind = "connection" + nodeRBAC promptNodeKind = "rbac" + nodeFileStore promptNodeKind = "file_store" + nodeSkill promptNodeKind = "skill" + nodeToolbox promptNodeKind = "toolbox" +) + +// promptNode is a single dependency in the prompt-agent deploy graph. Validate +// is pure and runs for every node before any Resolve executes, so a graph is +// fully validated before the first live mutation. Resolve is idempotent and +// create-if-missing; it writes any outputs later nodes consume into +// promptGraph.bindings. +type promptNode struct { + Kind promptNodeKind + ID string + Validate func() error + Resolve func(ctx context.Context) error +} + +// promptGraph is the internal, non-user-facing dependency graph for one +// prompt-agent deploy. It is derived from the agent folder plus agent.yaml, +// validated as a whole, then resolved in registration (dependency) order. None +// of this machinery is exposed in the YAML. +type promptGraph struct { + // agentDir is the folder holding agent.yaml plus any convention folders + // (instructions.md, files/, skills/). + agentDir string + + // managed is the parsed agent definition. Nodes may enrich managed.Tools + // with resolved bindings (e.g. a file_search or mcp tool) before publish. + managed *agent_yaml.ManagedAgent + + // settings holds the resolved harness/connection target for the agent. + settings *PromptAgentSettings + + // env is a snapshot of azd environment values used to resolve targets. + env map[string]string + + // bindings holds symbolic outputs produced by resolved nodes (for example + // "vector_store_id" or "toolbox_mcp_url") that later nodes read. + bindings map[string]any + + // nodes is the ordered set of dependencies to validate and resolve. + nodes []promptNode +} + +// newPromptGraph builds a graph for the given agent. Only the agent node is +// registered today; file/skill/connection nodes are added by later stages. +func newPromptGraph( + agentDir string, + managed *agent_yaml.ManagedAgent, + settings *PromptAgentSettings, + env map[string]string, +) (*promptGraph, error) { + g := &promptGraph{ + agentDir: agentDir, + managed: managed, + settings: settings, + env: env, + bindings: map[string]any{}, + } + + // The model deployment is resolved first: create-if-missing so the harness + // has a model to bind to before the agent version is published. + if node := deploymentNode(g, func() (deploymentResolver, error) { + return provisionedDeploymentResolver{}, nil + }); node != nil { + g.nodes = append(g.nodes, *node) + } + + // Convention: a non-empty files/ folder contributes a file_search tool + // backed by an uploaded vector store. + files, err := scanFilesDir(agentDir) + if err != nil { + return nil, err + } + if node := fileStoreNode(g, files, func() (vectorStoreBuilder, error) { + return newFoundryVectorStoreBuilder(settings) + }); node != nil { + g.nodes = append(g.nodes, *node) + } + + // Convention: a non-empty skills/ folder (or an explicit toolbox reference) + // contributes an mcp tool backed by a Foundry toolbox version. + skills, err := scanSkillsDir(agentDir) + if err != nil { + return nil, err + } + if node := toolboxNode(g, skills, managed.Toolbox, func() (toolboxBuilder, error) { + return newFoundryToolboxBuilder(settings) + }); node != nil { + g.nodes = append(g.nodes, *node) + } + + // Declared connections are resolved last among the feature stages: existing + // connections are used as-is, missing ones are created (Entra default), and + // each referenced tool's required role is assigned. + if node := connectionsNode(g, func() (connectionResolver, error) { + return newFoundryConnectionResolver(settings) + }); node != nil { + g.nodes = append(g.nodes, *node) + } + + // The agent node is terminal and validated last. + g.nodes = append(g.nodes, g.agentNode()) + return g, nil +} + +// agentNode is the terminal node representing the published agent version. Its +// validation enforces the minimum contract (model + instructions) up front so +// the deploy fails before any dependency is resolved when the definition is +// incomplete. +func (g *promptGraph) agentNode() promptNode { + return promptNode{ + Kind: nodeAgent, + ID: g.managed.Name, + Validate: func() error { + if strings.TrimSpace(g.managed.Model) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "prompt agent requires a non-empty model", + "set 'model' in agent.yaml (e.g. model: gpt-4.1-mini)", + ) + } + if strings.TrimSpace(g.managed.Instructions) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "prompt agent requires non-empty instructions", + "set 'instructions' in agent.yaml or add a sibling instructions.md", + ) + } + return nil + }, + Resolve: func(ctx context.Context) error { return nil }, + } +} + +// resolve validates the entire graph, then resolves each node in registration +// order. Validation runs to completion before any Resolve so a failure never +// leaves a half-wired agent. +func (g *promptGraph) resolve(ctx context.Context, progress azdext.ProgressReporter) error { + for _, n := range g.nodes { + if n.Validate == nil { + continue + } + if err := n.Validate(); err != nil { + return err + } + } + + for _, n := range g.nodes { + if n.Resolve == nil { + continue + } + if progress != nil { + progress(fmt.Sprintf("Resolving %s", n.Kind)) + } + if err := n.Resolve(ctx); err != nil { + return err + } + } + + return nil +} + +// resolvePromptAgentGraph builds and resolves the deploy graph for a prompt +// agent. It is called by deployPromptAgent before the create request is built, +// so any resolved bindings are reflected in the published agent definition. +func (p *AgentServiceTargetProvider) resolvePromptAgentGraph( + ctx context.Context, + managed *agent_yaml.ManagedAgent, + settings *PromptAgentSettings, + env map[string]string, + progress azdext.ProgressReporter, +) error { + agentDir := "" + if p.agentDefinitionPath != "" { + agentDir = filepath.Dir(p.agentDefinitionPath) + } + g, err := newPromptGraph(agentDir, managed, settings, env) + if err != nil { + return err + } + return g.resolve(ctx, progress) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go new file mode 100644 index 00000000000..c682d665103 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go @@ -0,0 +1,402 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + + "github.com/braydonk/yaml" +) + +// promptSkillsDirName is the conventional folder whose subfolders are Agent-Skills +// bundles registered into a toolbox and attached via an mcp tool. +const promptSkillsDirName = "skills" + +// skillFileName is the required manifest inside each skill bundle. +const skillFileName = "SKILL.md" + +// toolboxMcpURLBindingKey is the graph binding under which the resolved toolbox +// MCP url is published for later nodes / observability. +const toolboxMcpURLBindingKey = "toolbox_mcp_url" + +// skillMeta is the parsed SKILL.md content: the required frontmatter fields plus +// the Markdown body that becomes the skill's injected instructions. Version is +// optional (the service assigns one); when set via metadata.version it pins the +// toolbox skill reference to that immutable snapshot. +type skillMeta struct { + Name string + Description string + Version string + Instructions string +} + +// skillBundle is one skills// directory with its parsed metadata. +type skillBundle struct { + // Dir is the subfolder name (used as the skill/toolbox label). + Dir string + // Path is the absolute path to the bundle directory. + Path string + // Meta is the parsed SKILL.md frontmatter. + Meta skillMeta +} + +// toolboxRef identifies an existing toolbox to attach by reference. +type toolboxRef struct { + Name string + Version string +} + +// toolboxBuilder registers skills into a toolbox version (primary path) or +// resolves an existing toolbox (reference path), returning the toolbox MCP url. +// The seam keeps the graph node unit-testable without a live endpoint. +type toolboxBuilder interface { + // EnsureToolbox registers the skills into a toolbox named toolboxName and + // returns its MCP url. + EnsureToolbox(ctx context.Context, toolboxName string, skills []skillBundle) (mcpURL string, err error) + // ResolveToolbox returns the MCP url of an existing toolbox version. + ResolveToolbox(ctx context.Context, ref toolboxRef) (mcpURL string, err error) +} + +// scanSkillsDir returns the skill bundles under /skills, one per +// subfolder, sorted by name. Each bundle's SKILL.md is parsed. A missing or +// empty folder returns (nil, nil). +func scanSkillsDir(agentDir string) ([]skillBundle, error) { + if strings.TrimSpace(agentDir) == "" { + return nil, nil + } + dir := filepath.Join(agentDir, promptSkillsDirName) + + f, err := os.Open(dir) //nolint:gosec // agentDir derives from the resolved agent.yaml path + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("opening skills directory %q: %w", dir, err) + } + names, err := f.Readdirnames(-1) + _ = f.Close() + if err != nil { + return nil, fmt.Errorf("reading skills directory %q: %w", dir, err) + } + + var bundles []skillBundle + for _, name := range names { + if strings.HasPrefix(name, ".") { + continue + } + bundleDir := filepath.Join(dir, name) + info, statErr := os.Stat(bundleDir) + if statErr != nil { + return nil, fmt.Errorf("stat %q: %w", bundleDir, statErr) + } + if !info.IsDir() { + continue + } + meta, parseErr := parseSkillMD(filepath.Join(bundleDir, skillFileName)) + if parseErr != nil { + return nil, parseErr + } + if strings.TrimSpace(meta.Name) == "" { + meta.Name = name + } + bundles = append(bundles, skillBundle{Dir: name, Path: bundleDir, Meta: meta}) + } + + slices.SortFunc(bundles, func(a, b skillBundle) int { + return strings.Compare(a.Dir, b.Dir) + }) + return bundles, nil +} + +// parseSkillMD parses the frontmatter of a SKILL.md file. The frontmatter is a +// YAML block delimited by leading and trailing `---` lines. name, description, +// and metadata.version are required. +func parseSkillMD(path string) (skillMeta, error) { + data, err := os.ReadFile(path) //nolint:gosec // path derived from the agent's skills/ folder + if err != nil { + return skillMeta{}, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("failed to read %s: %s", skillFileName, err), + "ensure each skills// folder contains a SKILL.md file", + ) + } + + front, err := extractFrontmatter(string(data)) + if err != nil { + return skillMeta{}, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("%s at %q: %s", skillFileName, path, err), + "add a YAML frontmatter block delimited by --- at the top of SKILL.md", + ) + } + + var fm struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Metadata struct { + Version string `yaml:"version"` + } `yaml:"metadata"` + } + if err := yaml.Unmarshal([]byte(front.frontmatter), &fm); err != nil { + return skillMeta{}, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("%s frontmatter at %q is not valid YAML: %s", skillFileName, path, err), + "fix the SKILL.md frontmatter", + ) + } + + meta := skillMeta{ + Name: fm.Name, + Description: fm.Description, + Version: fm.Metadata.Version, + Instructions: front.body, + } + if strings.TrimSpace(meta.Description) == "" { + return skillMeta{}, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("%s at %q is missing 'description'", skillFileName, path), + "add a description to the SKILL.md frontmatter", + ) + } + // Version is optional: the Skills API assigns a version when omitted. When + // present (metadata.version) it pins the toolbox reference to that snapshot. + return meta, nil +} + +// frontmatterResult holds the split of a SKILL.md into its YAML frontmatter and +// the Markdown body that follows it. +type frontmatterResult struct { + frontmatter string + body string +} + +// extractFrontmatter splits SKILL.md into the YAML block between the first two +// `---` lines and the Markdown body after it. +func extractFrontmatter(content string) (frontmatterResult, error) { + trimmed := strings.TrimLeft(content, "\ufeff \t\r\n") + if !strings.HasPrefix(trimmed, "---") { + return frontmatterResult{}, fmt.Errorf("missing frontmatter delimiter") + } + // Drop the opening delimiter line. + rest := trimmed[len("---"):] + rest = strings.TrimLeft(rest, "\r\n") + end := strings.Index(rest, "\n---") + if end < 0 { + return frontmatterResult{}, fmt.Errorf("unterminated frontmatter block") + } + front := rest[:end] + // The body starts after the closing `---` line. + after := rest[end+len("\n---"):] + after = strings.TrimPrefix(after, "-") // tolerate longer --- fences + after = strings.TrimLeft(after, "-\r\n") // consume the rest of the fence line + return frontmatterResult{frontmatter: front, body: strings.TrimLeft(after, "\r\n")}, nil +} + +// injectMcpTool ensures the agent's tools include an mcp tool for the given +// toolbox label and MCP url. An existing mcp tool with the same server_url is +// left in place (not duplicated). The managed definition is mutated in place. +func injectMcpTool(managed *agent_yaml.ManagedAgent, serverLabel, mcpURL string) { + if managed == nil || strings.TrimSpace(mcpURL) == "" { + return + } + for _, raw := range managed.Tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if fmt.Sprintf("%v", tool["type"]) != "mcp" { + continue + } + if fmt.Sprintf("%v", tool["server_url"]) == mcpURL { + return // already present + } + } + managed.Tools = append(managed.Tools, map[string]any{ + "type": "mcp", + "server_label": serverLabel, + "server_url": mcpURL, + "require_approval": "always", + }) +} + +// toolboxNode builds the skill/toolbox graph node. When ref is non-nil the +// existing toolbox is attached by reference; otherwise the skill bundles are +// registered into a new toolbox version. Returns nil when there is nothing to +// attach (no skills and no reference). +func toolboxNode( + g *promptGraph, + skills []skillBundle, + ref *agent_yaml.ToolboxReference, + newBuilder func() (toolboxBuilder, error), +) *promptNode { + if len(skills) == 0 && ref == nil { + return nil + } + return &promptNode{ + Kind: nodeToolbox, + ID: promptSkillsDirName, + Validate: func() error { + // SKILL.md parsing already validated name/description/body in + // scanSkillsDir. Version is optional (service-assigned), so nothing + // further to check per-skill here. + for _, s := range skills { + if strings.TrimSpace(s.Meta.Instructions) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("skill %q has no instructions (empty SKILL.md body)", s.Dir), + "add Markdown content below the frontmatter in the skill's SKILL.md", + ) + } + } + if ref != nil && strings.TrimSpace(ref.Name) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "toolbox reference is missing a name", + "set toolbox.name in agent.yaml", + ) + } + return nil + }, + Resolve: func(ctx context.Context) error { + builder, err := newBuilder() + if err != nil { + return err + } + + var ( + mcpURL string + label string + ) + if ref != nil { + label = ref.Name + mcpURL, err = builder.ResolveToolbox(ctx, toolboxRef{Name: ref.Name, Version: ref.Version}) + } else { + label = g.managed.Name + mcpURL, err = builder.EnsureToolbox(ctx, g.managed.Name, skills) + } + if err != nil { + return err + } + + g.bindings[toolboxMcpURLBindingKey] = mcpURL + injectMcpTool(g.managed, label, mcpURL) + return nil + }, + } +} + +// foundryToolboxBuilder is the live toolboxBuilder backed by the Foundry skill +// and toolbox data-plane endpoints. +type foundryToolboxBuilder struct { + skills *azure.FoundrySkillsClient + toolboxes *azure.FoundryToolboxClient + projectEndpoint string +} + +// EnsureToolbox registers each skill bundle at its pinned version, creates a +// toolbox version referencing them, and returns the toolbox MCP url. +func (b *foundryToolboxBuilder) EnsureToolbox( + ctx context.Context, toolboxName string, skills []skillBundle, +) (string, error) { + // Skills are attached to a toolbox via a separate `skills` array of skill + // references (distinct from `tools`), per the Foundry Skills API. + skillRefs := make([]map[string]any, 0, len(skills)) + for _, s := range skills { + instructions := s.Meta.Instructions + if strings.TrimSpace(instructions) == "" { + // Fall back to the raw file when the body was empty so the service + // still receives non-empty instructions. + content, err := os.ReadFile(filepath.Join(s.Path, skillFileName)) //nolint:gosec // path from skills/ folder + if err != nil { + return "", fmt.Errorf("reading %s for skill %q: %w", skillFileName, s.Meta.Name, err) + } + instructions = string(content) + } + + version, err := b.skills.CreateSkillVersion(ctx, s.Meta.Name, &azure.CreateSkillVersionRequest{ + InlineContent: azure.SkillInlineContent{ + Description: s.Meta.Description, + Instructions: instructions, + }, + }) + if err != nil { + return "", fmt.Errorf("registering skill %q: %w", s.Meta.Name, err) + } + + ref := map[string]any{ + "type": "skill_reference", + "name": version.Name, + } + // Pin the reference to the created version only when the author pinned a + // version; otherwise follow the skill's default_version. + if strings.TrimSpace(s.Meta.Version) != "" { + ref["version"] = version.Version + } + skillRefs = append(skillRefs, ref) + } + + created, err := b.toolboxes.CreateToolboxVersion(ctx, toolboxName, &azure.CreateToolboxVersionRequest{ + Tools: []map[string]any{}, + Skills: skillRefs, + }) + if err != nil { + return "", fmt.Errorf("creating toolbox version: %w", err) + } + return b.mcpURL(created.Name, created.Version), nil +} + +// ResolveToolbox confirms an existing toolbox and returns its MCP url. When the +// reference pins a version, the version-specific (developer) endpoint is used; +// otherwise the consumer endpoint that always serves the default_version. +func (b *foundryToolboxBuilder) ResolveToolbox(ctx context.Context, ref toolboxRef) (string, error) { + if _, err := b.toolboxes.GetToolbox(ctx, ref.Name); err != nil { + return "", fmt.Errorf("resolving toolbox %q: %w", ref.Name, err) + } + return b.mcpURL(ref.Name, ref.Version), nil +} + +// mcpURL builds the toolbox MCP endpoint. With a version it returns the +// version-specific (developer) endpoint; without one it returns the consumer +// endpoint that always serves the toolbox's default_version. Both carry the +// required api-version query parameter. +func (b *foundryToolboxBuilder) mcpURL(name, version string) string { + base := strings.TrimRight(b.projectEndpoint, "/") + if strings.TrimSpace(version) == "" { + return fmt.Sprintf("%s/toolboxes/%s/mcp?api-version=%s", base, name, toolboxMcpApiVersion) + } + return fmt.Sprintf( + "%s/toolboxes/%s/versions/%s/mcp?api-version=%s", + base, name, version, toolboxMcpApiVersion, + ) +} + +// toolboxMcpApiVersion is the api-version query parameter required on toolbox +// MCP endpoint URLs. +const toolboxMcpApiVersion = "v1" + +// newFoundryToolboxBuilder constructs the live builder from prompt settings. +func newFoundryToolboxBuilder(settings *PromptAgentSettings) (toolboxBuilder, error) { + if settings == nil || strings.TrimSpace(settings.ProjectEndpoint) == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "a Foundry project endpoint is required to register skills / resolve a toolbox", + "run `azd up` to provision a Foundry project, or remove the skills/ folder", + ) + } + cred := promptCredential() + return &foundryToolboxBuilder{ + skills: azure.NewFoundrySkillsClient(settings.ProjectEndpoint, cred), + toolboxes: azure.NewFoundryToolboxClient(settings.ProjectEndpoint, cred), + projectEndpoint: settings.ProjectEndpoint, + }, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go new file mode 100644 index 00000000000..ed4d7c276fa --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" +) + +// fakeToolboxBuilder records calls and returns a fixed MCP url. +type fakeToolboxBuilder struct { + mcpURL string + ensureCalls int + resolveCalls int + lastSkills []skillBundle + lastRef toolboxRef +} + +func (b *fakeToolboxBuilder) EnsureToolbox( + _ context.Context, _ string, skills []skillBundle, +) (string, error) { + b.ensureCalls++ + b.lastSkills = skills + if b.mcpURL == "" { + b.mcpURL = "https://proj/toolboxes/agent/versions/1/mcp" + } + return b.mcpURL, nil +} + +func (b *fakeToolboxBuilder) ResolveToolbox(_ context.Context, ref toolboxRef) (string, error) { + b.resolveCalls++ + b.lastRef = ref + if b.mcpURL == "" { + b.mcpURL = "https://proj/toolboxes/existing/versions/2/mcp" + } + return b.mcpURL, nil +} + +func writeSkillsDir(t *testing.T, skills map[string]string) string { + t.Helper() + dir := t.TempDir() + if skills == nil { + return dir + } + for name, skillMD := range skills { + bundle := filepath.Join(dir, "skills", name) + if err := os.MkdirAll(bundle, 0o750); err != nil { + t.Fatalf("mkdir %s: %v", name, err) + } + if err := os.WriteFile(filepath.Join(bundle, "SKILL.md"), []byte(skillMD), 0o600); err != nil { + t.Fatalf("write SKILL.md: %v", err) + } + } + return dir +} + +const validSkillMD = `--- +name: agentdevcompute +description: Helps with dev compute tasks. +metadata: + version: 1.2.0 +--- +# Body +Some skill instructions. +` + +func TestParseSkillMD_Valid(t *testing.T) { + dir := writeSkillsDir(t, map[string]string{"agentdevcompute": validSkillMD}) + meta, err := parseSkillMD(filepath.Join(dir, "skills", "agentdevcompute", "SKILL.md")) + if err != nil { + t.Fatalf("parseSkillMD: %v", err) + } + if meta.Name != "agentdevcompute" || meta.Description == "" || meta.Version != "1.2.0" { + t.Errorf("meta: got %+v", meta) + } + if !strings.Contains(meta.Instructions, "Some skill instructions.") { + t.Errorf("instructions body not captured: got %q", meta.Instructions) + } +} + +func TestParseSkillMD_VersionOptional(t *testing.T) { + md := `--- +name: s +description: has no version, which is allowed +--- +body content +` + dir := writeSkillsDir(t, map[string]string{"s": md}) + meta, err := parseSkillMD(filepath.Join(dir, "skills", "s", "SKILL.md")) + if err != nil { + t.Fatalf("version should be optional: %v", err) + } + if meta.Version != "" { + t.Errorf("expected empty version, got %q", meta.Version) + } + if !strings.Contains(meta.Instructions, "body content") { + t.Errorf("instructions: got %q", meta.Instructions) + } +} + +func TestParseSkillMD_MissingDescription(t *testing.T) { + md := `--- +name: s +metadata: + version: 1.0.0 +--- +body +` + dir := writeSkillsDir(t, map[string]string{"s": md}) + _, err := parseSkillMD(filepath.Join(dir, "skills", "s", "SKILL.md")) + if err == nil || !strings.Contains(err.Error(), "description") { + t.Fatalf("expected description error, got %v", err) + } +} + +func TestParseSkillMD_NoFrontmatter(t *testing.T) { + md := "# Just a heading\nno frontmatter\n" + dir := writeSkillsDir(t, map[string]string{"s": md}) + _, err := parseSkillMD(filepath.Join(dir, "skills", "s", "SKILL.md")) + if err == nil { + t.Fatal("expected error for missing frontmatter") + } +} + +func TestScanSkillsDir_MultipleBundlesSorted(t *testing.T) { + skillB := strings.Replace(validSkillMD, "agentdevcompute", "bravo", 1) + skillA := strings.Replace(validSkillMD, "agentdevcompute", "alpha", 1) + dir := writeSkillsDir(t, map[string]string{"bravo": skillB, "alpha": skillA}) + + bundles, err := scanSkillsDir(dir) + if err != nil { + t.Fatalf("scanSkillsDir: %v", err) + } + if len(bundles) != 2 { + t.Fatalf("bundles: got %d, want 2", len(bundles)) + } + if bundles[0].Dir != "alpha" || bundles[1].Dir != "bravo" { + t.Errorf("sort: got %s, %s", bundles[0].Dir, bundles[1].Dir) + } +} + +func TestScanSkillsDir_Empty(t *testing.T) { + dir := writeSkillsDir(t, nil) + bundles, err := scanSkillsDir(dir) + if err != nil { + t.Fatalf("scanSkillsDir: %v", err) + } + if bundles != nil { + t.Errorf("expected nil for missing skills/, got %d", len(bundles)) + } +} + +func TestInjectMcpTool_AddsWhenAbsent(t *testing.T) { + managed := &agent_yaml.ManagedAgent{} + injectMcpTool(managed, "toolbox-a", "https://proj/mcp") + + if len(managed.Tools) != 1 { + t.Fatalf("tools: got %d, want 1", len(managed.Tools)) + } + tool := managed.Tools[0].(map[string]any) + if tool["type"] != "mcp" || tool["server_url"] != "https://proj/mcp" { + t.Errorf("tool: got %+v", tool) + } +} + +func TestInjectMcpTool_NotDuplicated(t *testing.T) { + managed := &agent_yaml.ManagedAgent{ + Tools: []any{ + map[string]any{"type": "mcp", "server_url": "https://proj/mcp"}, + }, + } + injectMcpTool(managed, "toolbox-a", "https://proj/mcp") + if len(managed.Tools) != 1 { + t.Errorf("expected no duplicate mcp tool, got %d", len(managed.Tools)) + } +} + +func TestToolboxNode_PrimaryRegistersSkills(t *testing.T) { + managed := &agent_yaml.ManagedAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeToolboxBuilder{} + + skills := []skillBundle{{Dir: "s", Meta: skillMeta{ + Name: "s", Description: "d", Version: "1.0.0", Instructions: "do the thing", + }}} + node := toolboxNode(g, skills, nil, func() (toolboxBuilder, error) { return fake, nil }) + if node == nil { + t.Fatal("expected a toolbox node") + } + if err := node.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if fake.ensureCalls != 1 || fake.resolveCalls != 0 { + t.Errorf("expected 1 ensure, 0 resolve; got %d, %d", fake.ensureCalls, fake.resolveCalls) + } + if g.bindings[toolboxMcpURLBindingKey] == nil { + t.Error("expected toolbox_mcp_url binding") + } + if len(managed.Tools) != 1 || managed.Tools[0].(map[string]any)["type"] != "mcp" { + t.Errorf("expected mcp tool, got %+v", managed.Tools) + } +} + +func TestToolboxNode_FallbackReferenceExisting(t *testing.T) { + managed := &agent_yaml.ManagedAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeToolboxBuilder{} + + ref := &agent_yaml.ToolboxReference{Name: "existing-tb", Version: "2"} + node := toolboxNode(g, nil, ref, func() (toolboxBuilder, error) { return fake, nil }) + if node == nil { + t.Fatal("expected a toolbox node") + } + if err := node.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if fake.resolveCalls != 1 || fake.ensureCalls != 0 { + t.Errorf("expected 1 resolve, 0 ensure; got %d, %d", fake.resolveCalls, fake.ensureCalls) + } + if fake.lastRef.Name != "existing-tb" || fake.lastRef.Version != "2" { + t.Errorf("ref: got %+v", fake.lastRef) + } + if len(managed.Tools) != 1 { + t.Errorf("expected mcp tool attached, got %+v", managed.Tools) + } +} + +func TestToolboxNode_NoneReturnsNil(t *testing.T) { + g := &promptGraph{managed: &agent_yaml.ManagedAgent{}, bindings: map[string]any{}} + node := toolboxNode(g, nil, nil, func() (toolboxBuilder, error) { return nil, nil }) + if node != nil { + t.Fatal("expected nil node when no skills and no reference") + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go index e047f4b3312..a05da716058 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go @@ -9,6 +9,7 @@ import ( "fmt" "net/url" "os" + "path/filepath" "runtime/debug" "slices" "strings" @@ -72,6 +73,10 @@ func (p *AgentServiceTargetProvider) promptAgentSettings() (*PromptAgentSettings } // loadPromptAgentDefinition reads the agent.yaml as a bare ManagedAgent. +// +// Convention: when the YAML omits inline `instructions:`, a sibling +// `instructions.md` (next to agent.yaml) is used as the agent's instructions. +// Inline `instructions:` always takes precedence over the file. func (p *AgentServiceTargetProvider) loadPromptAgentDefinition() (agent_yaml.ManagedAgent, error) { data, err := os.ReadFile(p.agentDefinitionPath) if err != nil { @@ -81,6 +86,9 @@ func (p *AgentServiceTargetProvider) loadPromptAgentDefinition() (agent_yaml.Man "verify the agent.yaml file exists and is readable", ) } + if err := validatePromptAgentRawFields(data); err != nil { + return agent_yaml.ManagedAgent{}, err + } var managed agent_yaml.ManagedAgent if err := yaml.Unmarshal(data, &managed); err != nil { return agent_yaml.ManagedAgent{}, exterrors.Validation( @@ -96,9 +104,63 @@ func (p *AgentServiceTargetProvider) loadPromptAgentDefinition() (agent_yaml.Man "use kind: managed for prompt agents", ) } + + // Convention: fall back to a sibling instructions.md when instructions are + // not declared inline. Inline instructions win. + if strings.TrimSpace(managed.Instructions) == "" { + instructionsPath := filepath.Join(filepath.Dir(p.agentDefinitionPath), promptInstructionsFileName) + if content, readErr := os.ReadFile(instructionsPath); readErr == nil { + managed.Instructions = string(content) + } + } + return managed, nil } +// promptInstructionsFileName is the conventional sidecar file whose contents +// become the prompt agent's instructions when none are declared inline. +const promptInstructionsFileName = "instructions.md" + +// containerOnlyPromptFields lists agent.yaml keys that are only meaningful for +// hosted (container) agents and are therefore rejected for kind: prompt. +var containerOnlyPromptFields = []string{ + "image", + "protocols", + "agent_endpoint", + "agent_card", + "code_configuration", + "docker", + "runtime", + "startupCommand", + "startup_command", +} + +// validatePromptAgentRawFields rejects container-only fields on a prompt agent. +// +// The YAML decoder silently drops unknown fields, so a probe decode into a +// generic map is used to detect container-only keys that the typed ManagedAgent +// would otherwise ignore, surfacing a clear error instead of silently ignoring +// misplaced configuration. +func validatePromptAgentRawFields(data []byte) error { + var probe map[string]any + if err := yaml.Unmarshal(data, &probe); err != nil { + // A malformed document is reported by the typed decode with a better + // message; don't duplicate the error here. + return nil + } + for _, field := range containerOnlyPromptFields { + if _, ok := probe[field]; ok { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("field %q is not valid for a prompt (kind: managed) agent", field), + "remove container-only fields (image, protocols, code_configuration, ...) "+ + "or use kind: hosted for container agents", + ) + } + } + return nil +} + // deployPromptAgent creates (or updates) the prompt agent on the managed // harness and registers the resulting agent identity in the azd environment. // It is the prompt-agent analogue of deployHostedAgent, dispatched from @@ -176,6 +238,16 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( } } + // Resolve the prompt agent's dependency graph. This validates the whole + // graph (model + instructions, and — as later stages land — folders, + // connections, and skills) and resolves convention-based dependencies, + // enriching the definition before the create request is built. Env values + // are best-effort; a nil map simply means nothing to overlay. + graphEnv, _ := p.azdEnvValues(ctx) + if err := p.resolvePromptAgentGraph(ctx, &managed, settings, graphEnv, progress); err != nil { + return nil, err + } + request, err := agent_yaml.CreateManagedAgentAPIRequest(managed, nil) if err != nil { return nil, exterrors.Validation( From 7449984e0298f4bfcc18a520ad339ae22676b46c Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Fri, 10 Jul 2026 04:00:24 -0700 Subject: [PATCH 05/24] Remove obsolete demo, scratch agent project, and managed-harness specs --- .../demo/agent-init-list-show.md | 170 --- .../my-prompt-agent-0701-02/.gitignore | 1 - .../my-prompt-agent-0701-02/agent.yaml | 27 - .../my-prompt-agent-0701-02/azure.yaml | 32 - .../infra/abbreviations.json | 137 -- .../infra/core/ai/acr-role-assignment.bicep | 27 - .../infra/core/ai/ai-project.bicep | 417 ------ .../infra/core/ai/connection.bicep | 112 -- .../infra/core/ai/existing-ai-project.bicep | 140 -- .../infra/core/host/acr.bicep | 88 -- .../applicationinsights-dashboard.bicep | 1236 ----------------- .../core/monitor/applicationinsights.bicep | 47 - .../infra/core/monitor/loganalytics.bicep | 22 - .../infra/core/search/azure_ai_search.bicep | 211 --- .../core/search/bing_custom_grounding.bicep | 84 -- .../infra/core/search/bing_grounding.bicep | 83 -- .../infra/core/storage/storage.bicep | 113 -- .../my-prompt-agent-0701-02/infra/main.bicep | 248 ---- .../infra/main.parameters.json | 78 -- .../generate_getting_started.py | 239 ---- .../managed-harness-agents/generate_spec.py | 863 ------------ .../managed-agents-getting-started.docx | Bin 39145 -> 0 bytes .../managed-agents-getting-started.md | 178 --- docs/specs/managed-harness-agents/spec.docx | Bin 48114 -> 0 bytes .../~$naged-agents-getting-started.docx | Bin 162 -> 0 bytes 25 files changed, 4553 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.agents/demo/agent-init-list-show.md delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/.gitignore delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/agent.yaml delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/azure.yaml delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/abbreviations.json delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/acr-role-assignment.bicep delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/ai-project.bicep delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/connection.bicep delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/existing-ai-project.bicep delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/host/acr.bicep delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/applicationinsights-dashboard.bicep delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/applicationinsights.bicep delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/loganalytics.bicep delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/azure_ai_search.bicep delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/bing_custom_grounding.bicep delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/bing_grounding.bicep delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/storage/storage.bicep delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/main.bicep delete mode 100644 cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/main.parameters.json delete mode 100644 docs/specs/managed-harness-agents/generate_getting_started.py delete mode 100644 docs/specs/managed-harness-agents/generate_spec.py delete mode 100644 docs/specs/managed-harness-agents/managed-agents-getting-started.docx delete mode 100644 docs/specs/managed-harness-agents/managed-agents-getting-started.md delete mode 100644 docs/specs/managed-harness-agents/spec.docx delete mode 100644 docs/specs/managed-harness-agents/~$naged-agents-getting-started.docx diff --git a/cli/azd/extensions/azure.ai.agents/demo/agent-init-list-show.md b/cli/azd/extensions/azure.ai.agents/demo/agent-init-list-show.md deleted file mode 100644 index 9f3547a6003..00000000000 --- a/cli/azd/extensions/azure.ai.agents/demo/agent-init-list-show.md +++ /dev/null @@ -1,170 +0,0 @@ -# azd ai agent — Demo Script (init → list → show) - -A recording-ready script for a short screencast of the `azure.ai.agents` extension. -Each section has **[NARRATION]** (what to say) and **[RUN]** (what to type on screen). - -- Target time: ~3–4 minutes -- Shell: PowerShell -- Pre-req: `azd auth login` already done, an existing Foundry project available - ---- - -## 0. Setup (do this BEFORE recording — keep off-camera) - -```powershell -# Clean, empty folder for the demo -New-Item -ItemType Directory -Force -Path "$HOME\azd-agent-demo" | Out-Null -Set-Location "$HOME\azd-agent-demo" - -# Make output deterministic and clean for capture -$env:NO_COLOR = "1" # stable text, no ANSI escapes -$env:AZURE_CORE_OUTPUT = "none" - -# Confirm the extension is installed -azd extension list | Select-String "azure.ai.agents" -``` - -> Tip: Increase terminal font size and clear scrollback (`Clear-Host`) right before you hit record. - ---- - -## 1. Intro (10–15s) - -**[NARRATION]** -> "In this short demo I'll create a prompt-based AI agent with the Azure Developer CLI, -> then use the agent lifecycle commands to list it and inspect its status — -> all without leaving the terminal." - -**[RUN]** -```powershell -Clear-Host -azd version -``` - ---- - -## 2. `azd ai agent init` (60–90s) - -**[NARRATION]** -> "First, `azd ai agent init`. This scaffolds a new agent project: it walks me through -> picking a subscription and a Foundry project, selecting a model, and it writes an -> `azure.yaml`, an `agent.yaml` manifest, and the infrastructure to provision." - -**[RUN]** -```powershell -azd ai agent init -``` - -**On-screen choices to make (call these out as you click):** -1. Agent type → **Prompt agent** (managed) -2. Subscription → your demo subscription -3. Foundry project → **Use an existing Foundry project** → pick your project -4. Model deployment → e.g. **gpt-4.1-mini** -5. Agent name → **my-demo-agent** - -**[NARRATION] (while files generate)** -> "Notice it generated everything I need: the service definition, the agent manifest, -> and a Bicep template. Let me show the two key files." - -**[RUN]** -```powershell -Get-Content azure.yaml -Get-Content agent.yaml -``` - -**[NARRATION]** -> "The `agent.yaml` is the heart of the agent — its kind, model, and the instructions -> that define its behavior." - ---- - -## 3. Provision + deploy the agent (45–60s) - -**[NARRATION]** -> "Now I'll run `azd up`. This provisions any required resources and then creates the -> agent on the managed Foundry harness." - -**[RUN]** -```powershell -azd up -``` - -**[NARRATION] (when it finishes)** -> "Deployment succeeded. The agent is now live on my Foundry project. -> Let's use the lifecycle commands to confirm that." - ---- - -## 4. `azd ai agent list` (30–40s) - -**[NARRATION]** -> "`azd ai agent list` shows every agent on the project this environment is connected to, -> with its version and status." - -**[RUN]** -```powershell -azd ai agent list -``` - -**[NARRATION]** -> "There's `my-demo-agent`, version 1, status active. The same project can host multiple -> agents and they all show up here." - ---- - -## 5. `azd ai agent show` (40–60s) - -**[NARRATION]** -> "To inspect a single agent, I use `azd ai agent show`. By default it prints a concise -> status table." - -**[RUN]** -```powershell -azd ai agent show -``` - -**[NARRATION]** -> "Name, kind, version, status, and the harness endpoint. And because azd is built for -> automation, I can get the full object as JSON for scripting." - -**[RUN]** -```powershell -azd ai agent show --output json -``` - -**[NARRATION]** -> "Here's the complete agent definition — the model, the instructions, the managed -> identity, and the version metadata — exactly what you'd pipe into another tool." - ---- - -## 6. Wrap-up (10–15s) - -**[NARRATION]** -> "And that's the core loop: `init` to scaffold, `azd up` to deploy, then `list` and `show` -> to manage your agents — a complete, terminal-first workflow for Azure AI agents. -> Thanks for watching." - -**[RUN] (optional teardown, off-camera)** -```powershell -azd down --purge --force -``` - ---- - -## Quick command cheat-sheet (for the description / pinned comment) - -```text -azd ai agent init # scaffold a new agent project -azd up # provision + deploy the agent -azd ai agent list # list agents on the project -azd ai agent show # show status of the resolved agent (table) -azd ai agent show --output json # full agent object as JSON -``` - -## Recording tips - -- Set `NO_COLOR=1` so captured text stays clean and copy-pasteable. -- Run each block once **before** recording to warm caches (first run can be slower). -- If a command is long-running, plan a jump-cut at the "creating prompt agent" step. -- Keep the window at a fixed size so zoom/crop is consistent across takes. diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/.gitignore b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/.gitignore deleted file mode 100644 index 8e84380248d..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.azure diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/agent.yaml b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/agent.yaml deleted file mode 100644 index f988ecb9b2f..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/agent.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ManagedAgent.yaml - -kind: managed -name: my-prompt-agent-0701-02-3 -model: gpt-4.1-mini -instructions: You are a helpful AI assistant. -tool_choice: auto -tools: - - type: function - name: calculate_sum - description: Adds two numbers - parameters: - type: object - properties: { a: { type: number }, b: { type: number } } - required: [a, b] - strict: true - - type: code_interpreter - container: auto - - type: mcp - server_label: github-mcp - server_url: https://api.githubcopilot.com/mcp - require_approval: always - - type: bing_grounding - bing_grounding: - search_configurations: - - project_connection_id: conn_bing_456 - - type: toolbox_search_preview \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/azure.yaml b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/azure.yaml deleted file mode 100644 index 2399256b7b0..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/azure.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json - -requiredVersions: - extensions: - azure.ai.agents: '>=0.1.0-preview' -name: ai-foundry-starter-basic -services: - my-prompt-agent-0701-02: - project: . - host: azure.ai.agent - language: "" - config: - deployments: - - model: - format: OpenAI - name: gpt-4.1-mini - version: "2025-04-14" - name: gpt-4.1-mini - sku: - capacity: 10 - name: GlobalStandard - promptAgent: - apiVersion: v1 - baseUrl: https://ai.azure.com/api - modelEndpoint: https://kchawla-wus2-0726.services.ai.azure.com - projectEndpoint: https://kchawla-wus2-0726.services.ai.azure.com/api/projects/kchawla-wus2-0726-project - resourceGroup: kchawla-rg-wus2 - subscriptionId: 2d385bf4-0756-4a76-aa95-28bf9ed3b625 - workspace: kchawla-wus2-0726-project -infra: - provider: bicep - path: ./infra diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/abbreviations.json b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/abbreviations.json deleted file mode 100644 index 879b2a9507b..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/abbreviations.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "aiFoundryAccounts": "aif", - "analysisServicesServers": "as", - "apiManagementService": "apim-", - "appConfigurationStores": "appcs-", - "appManagedEnvironments": "cae-", - "appContainerApps": "ca-", - "authorizationPolicyDefinitions": "policy-", - "automationAutomationAccounts": "aa-", - "blueprintBlueprints": "bp-", - "blueprintBlueprintsArtifacts": "bpa-", - "cacheRedis": "redis-", - "cdnProfiles": "cdnp-", - "cdnProfilesEndpoints": "cdne-", - "cognitiveServicesAccounts": "cog-", - "cognitiveServicesFormRecognizer": "cog-fr-", - "cognitiveServicesTextAnalytics": "cog-ta-", - "computeAvailabilitySets": "avail-", - "computeCloudServices": "cld-", - "computeDiskEncryptionSets": "des", - "computeDisks": "disk", - "computeDisksOs": "osdisk", - "computeGalleries": "gal", - "computeSnapshots": "snap-", - "computeVirtualMachines": "vm", - "computeVirtualMachineScaleSets": "vmss-", - "containerInstanceContainerGroups": "ci", - "containerRegistryRegistries": "cr", - "containerServiceManagedClusters": "aks-", - "databricksWorkspaces": "dbw-", - "dataFactoryFactories": "adf-", - "dataLakeAnalyticsAccounts": "dla", - "dataLakeStoreAccounts": "dls", - "dataMigrationServices": "dms-", - "dBforMySQLServers": "mysql-", - "dBforPostgreSQLServers": "psql-", - "devicesIotHubs": "iot-", - "devicesProvisioningServices": "provs-", - "devicesProvisioningServicesCertificates": "pcert-", - "documentDBDatabaseAccounts": "cosmos-", - "documentDBMongoDatabaseAccounts": "cosmon-", - "eventGridDomains": "evgd-", - "eventGridDomainsTopics": "evgt-", - "eventGridEventSubscriptions": "evgs-", - "eventHubNamespaces": "evhns-", - "eventHubNamespacesEventHubs": "evh-", - "hdInsightClustersHadoop": "hadoop-", - "hdInsightClustersHbase": "hbase-", - "hdInsightClustersKafka": "kafka-", - "hdInsightClustersMl": "mls-", - "hdInsightClustersSpark": "spark-", - "hdInsightClustersStorm": "storm-", - "hybridComputeMachines": "arcs-", - "insightsActionGroups": "ag-", - "insightsComponents": "appi-", - "keyVaultVaults": "kv-", - "kubernetesConnectedClusters": "arck", - "kustoClusters": "dec", - "kustoClustersDatabases": "dedb", - "logicIntegrationAccounts": "ia-", - "logicWorkflows": "logic-", - "machineLearningServicesWorkspaces": "mlw-", - "managedIdentityUserAssignedIdentities": "id-", - "managementManagementGroups": "mg-", - "migrateAssessmentProjects": "migr-", - "networkApplicationGateways": "agw-", - "networkApplicationSecurityGroups": "asg-", - "networkAzureFirewalls": "afw-", - "networkBastionHosts": "bas-", - "networkConnections": "con-", - "networkDnsZones": "dnsz-", - "networkExpressRouteCircuits": "erc-", - "networkFirewallPolicies": "afwp-", - "networkFirewallPoliciesWebApplication": "waf", - "networkFirewallPoliciesRuleGroups": "wafrg", - "networkFrontDoors": "fd-", - "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", - "networkLoadBalancersExternal": "lbe-", - "networkLoadBalancersInternal": "lbi-", - "networkLoadBalancersInboundNatRules": "rule-", - "networkLocalNetworkGateways": "lgw-", - "networkNatGateways": "ng-", - "networkNetworkInterfaces": "nic-", - "networkNetworkSecurityGroups": "nsg-", - "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", - "networkNetworkWatchers": "nw-", - "networkPrivateDnsZones": "pdnsz-", - "networkPrivateLinkServices": "pl-", - "networkPublicIPAddresses": "pip-", - "networkPublicIPPrefixes": "ippre-", - "networkRouteFilters": "rf-", - "networkRouteTables": "rt-", - "networkRouteTablesRoutes": "udr-", - "networkTrafficManagerProfiles": "traf-", - "networkVirtualNetworkGateways": "vgw-", - "networkVirtualNetworks": "vnet-", - "networkVirtualNetworksSubnets": "snet-", - "networkVirtualNetworksVirtualNetworkPeerings": "peer-", - "networkVirtualWans": "vwan-", - "networkVpnGateways": "vpng-", - "networkVpnGatewaysVpnConnections": "vcn-", - "networkVpnGatewaysVpnSites": "vst-", - "notificationHubsNamespaces": "ntfns-", - "notificationHubsNamespacesNotificationHubs": "ntf-", - "operationalInsightsWorkspaces": "log-", - "portalDashboards": "dash-", - "powerBIDedicatedCapacities": "pbi-", - "purviewAccounts": "pview-", - "recoveryServicesVaults": "rsv-", - "resourcesResourceGroups": "rg-", - "searchSearchServices": "srch-", - "serviceBusNamespaces": "sb-", - "serviceBusNamespacesQueues": "sbq-", - "serviceBusNamespacesTopics": "sbt-", - "serviceEndPointPolicies": "se-", - "serviceFabricClusters": "sf-", - "signalRServiceSignalR": "sigr", - "sqlManagedInstances": "sqlmi-", - "sqlServers": "sql-", - "sqlServersDataWarehouse": "sqldw-", - "sqlServersDatabases": "sqldb-", - "sqlServersDatabasesStretch": "sqlstrdb-", - "storageStorageAccounts": "st", - "storageStorageAccountsVm": "stvm", - "storSimpleManagers": "ssimp", - "streamAnalyticsCluster": "asa-", - "synapseWorkspaces": "syn", - "synapseWorkspacesAnalyticsWorkspaces": "synw", - "synapseWorkspacesSqlPoolsDedicated": "syndp", - "synapseWorkspacesSqlPoolsSpark": "synsp", - "timeSeriesInsightsEnvironments": "tsi-", - "webServerFarms": "plan-", - "webSitesAppService": "app-", - "webSitesAppServiceEnvironment": "ase-", - "webSitesFunctions": "func-", - "webStaticSites": "stapp-" -} diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/acr-role-assignment.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/acr-role-assignment.bicep deleted file mode 100644 index 3e0c2b218be..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/acr-role-assignment.bicep +++ /dev/null @@ -1,27 +0,0 @@ -targetScope = 'resourceGroup' - -@description('Name of the existing container registry') -param acrName string - -@description('Principal ID to grant AcrPull role') -param principalId string - -@description('Full resource ID of the ACR (for generating unique GUID)') -param acrResourceId string - -// Reference the existing ACR in this resource group -resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { - name: acrName -} - -// Grant AcrPull role to the AI project's managed identity -resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - scope: acr - name: guid(acrResourceId, principalId, '7f951dda-4ed3-4680-a7ca-43fe172d538d') - properties: { - principalId: principalId - principalType: 'ServicePrincipal' - // AcrPull role - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - } -} diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/ai-project.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/ai-project.bicep deleted file mode 100644 index 31b06ad76a2..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/ai-project.bicep +++ /dev/null @@ -1,417 +0,0 @@ -targetScope = 'resourceGroup' - -@description('Tags that will be applied to all resources') -param tags object = {} - -@description('Main location for the resources') -param location string - -@description('Optional salt to diversify resource names across project recreations') -param resourceTokenSalt string = '' - -var resourceToken = empty(resourceTokenSalt) ? uniqueString(subscription().id, resourceGroup().id, location) : uniqueString(subscription().id, resourceGroup().id, location, resourceTokenSalt) - -@description('Name of the project') -param aiFoundryProjectName string - -param deployments deploymentsType - -@description('Id of the user or app to assign application roles') -param principalId string - -@description('Principal type of user or app') -param principalType string - -@description('Optional. Name of an existing AI Services account in the current resource group. If not provided, a new one will be created.') -param existingAiAccountName string = '' - -@description('List of connections to provision') -param connections array = [] - -@secure() -@description('Map of connection name to credentials object. Kept as @secure to prevent secrets from appearing in deployment logs. Example: { "my-conn": { "key": "secret" } }') -param connectionCredentials object = {} - -@description('Also provision dependent resources and connect to the project') -param additionalDependentResources dependentResourcesType - -@description('Enable monitoring via appinsights and log analytics') -param enableMonitoring bool = true - -@description('Enable hosted agent deployment') -param enableHostedAgents bool = false - -@description('Enable the capability host for agent conversations. When false and hosted agents are enabled, the capability host is not created (v2 hosted agents handle storage automatically).') -param enableCapabilityHost bool = true - -@description('Optional. Existing container registry resource ID. If provided, a connection will be created to this ACR instead of creating a new one.') -param existingContainerRegistryResourceId string = '' - -@description('Optional. Existing container registry login server (e.g., myregistry.azurecr.io). Required if existingContainerRegistryResourceId is provided.') -param existingContainerRegistryEndpoint string = '' - -@description('Optional. Name of an existing ACR connection on the Foundry project. If provided, no new ACR or connection will be created.') -param existingAcrConnectionName string = '' - -@description('Optional. Existing Application Insights connection string. If provided, a connection will be created but no new App Insights resource.') -param existingApplicationInsightsConnectionString string = '' - -@description('Optional. Existing Application Insights resource ID. Used for connection metadata when providing an existing App Insights.') -param existingApplicationInsightsResourceId string = '' - -@description('Optional. Name of an existing Application Insights connection on the Foundry project. If provided, no new App Insights or connection will be created.') -param existingAppInsightsConnectionName string = '' - -// Load abbreviations -var abbrs = loadJsonContent('../../abbreviations.json') - -// Determine which resources to create based on connections -var hasStorageConnection = length(filter(additionalDependentResources, conn => conn.resource == 'storage')) > 0 -var hasAcrConnection = length(filter(additionalDependentResources, conn => conn.resource == 'registry')) > 0 -var hasExistingAcr = !empty(existingContainerRegistryResourceId) -var hasExistingAcrConnection = !empty(existingAcrConnectionName) -var hasExistingAppInsightsConnection = !empty(existingAppInsightsConnectionName) -var hasExistingAppInsightsConnectionString = !empty(existingApplicationInsightsConnectionString) -// Only create new App Insights resources if monitoring enabled and no existing connection/connection string -var shouldCreateAppInsights = enableMonitoring && !hasExistingAppInsightsConnection && !hasExistingAppInsightsConnectionString -var hasSearchConnection = length(filter(additionalDependentResources, conn => conn.resource == 'azure_ai_search')) > 0 -var hasBingConnection = length(filter(additionalDependentResources, conn => conn.resource == 'bing_grounding')) > 0 -var hasBingCustomConnection = length(filter(additionalDependentResources, conn => conn.resource == 'bing_custom_grounding')) > 0 - -// Extract connection names from ai.yaml for each resource type -var storageConnectionName = hasStorageConnection ? filter(additionalDependentResources, conn => conn.resource == 'storage')[0].connectionName : '' -var acrConnectionName = hasAcrConnection ? filter(additionalDependentResources, conn => conn.resource == 'registry')[0].connectionName : '' -var searchConnectionName = hasSearchConnection ? filter(additionalDependentResources, conn => conn.resource == 'azure_ai_search')[0].connectionName : '' -var bingConnectionName = hasBingConnection ? filter(additionalDependentResources, conn => conn.resource == 'bing_grounding')[0].connectionName : '' -var bingCustomConnectionName = hasBingCustomConnection ? filter(additionalDependentResources, conn => conn.resource == 'bing_custom_grounding')[0].connectionName : '' - -// Enable monitoring via Log Analytics and Application Insights -module logAnalytics '../monitor/loganalytics.bicep' = if (shouldCreateAppInsights) { - name: 'logAnalytics' - params: { - location: location - tags: tags - name: 'logs-${resourceToken}' - } -} - -module applicationInsights '../monitor/applicationinsights.bicep' = if (shouldCreateAppInsights) { - name: 'applicationInsights' - params: { - location: location - tags: tags - name: 'appi-${resourceToken}' - logAnalyticsWorkspaceId: logAnalytics.outputs.id - projectMIPrincipalId: aiAccount::project.identity.principalId - } -} - -// Always create a new AI Account for now (simplified approach) -// TODO: Add support for existing accounts in a future version -resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { - name: !empty(existingAiAccountName) ? existingAiAccountName : 'ai-account-${resourceToken}' - location: location - tags: tags - sku: { - name: 'S0' - } - kind: 'AIServices' - identity: { - type: 'SystemAssigned' - } - properties: { - allowProjectManagement: true - customSubDomainName: !empty(existingAiAccountName) ? existingAiAccountName : 'ai-account-${resourceToken}' - networkAcls: { - defaultAction: 'Allow' - virtualNetworkRules: [] - ipRules: [] - } - publicNetworkAccess: 'Enabled' - disableLocalAuth: true - } - - @batchSize(1) - resource seqDeployments 'deployments' = [ - for dep in (deployments??[]): { - name: dep.name - properties: { - model: dep.model - } - sku: dep.sku - } - ] - - resource project 'projects' = { - name: aiFoundryProjectName - location: location - identity: { - type: 'SystemAssigned' - } - properties: { - description: '${aiFoundryProjectName} Project' - displayName: '${aiFoundryProjectName}Project' - } - dependsOn: [ - seqDeployments - ] - } - - resource aiFoundryAccountCapabilityHost 'capabilityHosts@2025-10-01-preview' = if (enableHostedAgents && enableCapabilityHost) { - name: 'agents' - properties: { - capabilityHostKind: 'Agents' - // IMPORTANT: this is required to enable hosted agents deployment - // if no BYO Net is provided - enablePublicHostingEnvironment: true - } - } -} - - -// Create connection towards appinsights: -// - when we create a new App Insights resource, OR -// - when the user provided an existing App Insights connection string + resource ID but no existing connection name -// Both cases are merged into a single resource to avoid duplicate ARM resource definitions (which fail deployment). -var shouldCreateExistingAppInsightsConnection = enableMonitoring && hasExistingAppInsightsConnectionString && !hasExistingAppInsightsConnection && !empty(existingApplicationInsightsResourceId) -var shouldCreateAppInsightsConnection = shouldCreateAppInsights || shouldCreateExistingAppInsightsConnection - -resource appInsightConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (shouldCreateAppInsightsConnection) { - parent: aiAccount::project - name: 'appi-${resourceToken}' - properties: { - category: 'AppInsights' - target: shouldCreateAppInsights ? applicationInsights.outputs.id : existingApplicationInsightsResourceId - authType: 'ApiKey' - isSharedToAll: true - credentials: { - key: shouldCreateAppInsights ? applicationInsights.outputs.connectionString : existingApplicationInsightsConnectionString - } - metadata: { - ApiType: 'Azure' - ResourceId: shouldCreateAppInsights ? applicationInsights.outputs.id : existingApplicationInsightsResourceId - } - } -} - -// Create additional connections from ai.yaml configuration -module aiConnections './connection.bicep' = [for (connection, index) in connections: { - name: 'connection-${connection.name}' - params: { - aiServicesAccountName: aiAccount.name - aiProjectName: aiAccount::project.name - connectionConfig: connection - credentials: connectionCredentials[?connection.name] ?? {} - } -}] - -// Azure AI User for the developer, scoped to the Foundry Project. -// Project scope is sufficient for creating/running agents and calling models via the project endpoint. -resource localUserAzureAIUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - scope: aiAccount::project - name: guid(subscription().id, resourceGroup().id, principalId, '53ca6127-db72-4b80-b1b0-d745d6d5456d') - properties: { - principalId: principalId - principalType: principalType - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '53ca6127-db72-4b80-b1b0-d745d6d5456d') - } -} - - -// All connections are now created directly within their respective resource modules -// using the centralized ./connection.bicep module - -// Storage module - deploy if storage connection is defined in ai.yaml -module storage '../storage/storage.bicep' = if (hasStorageConnection) { - name: 'storage' - params: { - location: location - tags: tags - resourceName: 'st${resourceToken}' - connectionName: storageConnectionName - principalId: principalId - principalType: principalType - aiServicesAccountName: aiAccount.name - aiProjectName: aiAccount::project.name - } -} - -// Azure Container Registry module - deploy if ACR connection is defined in ai.yaml -module acr '../host/acr.bicep' = if (hasAcrConnection) { - name: 'acr' - params: { - location: location - tags: tags - resourceName: '${abbrs.containerRegistryRegistries}${resourceToken}' - connectionName: acrConnectionName - principalId: principalId - principalType: principalType - aiServicesAccountName: aiAccount.name - aiProjectName: aiAccount::project.name - } -} - -// Connection for existing ACR - create if user provided an existing ACR resource ID but no existing connection -module existingAcrConnection './connection.bicep' = if (hasExistingAcr && !hasExistingAcrConnection) { - name: 'existing-acr-connection' - params: { - aiServicesAccountName: aiAccount.name - aiProjectName: aiAccount::project.name - connectionConfig: { - name: 'acr-${resourceToken}' - category: 'ContainerRegistry' - target: existingContainerRegistryEndpoint - authType: 'ManagedIdentity' - isSharedToAll: true - metadata: { - ResourceId: existingContainerRegistryResourceId - } - } - credentials: { - clientId: aiAccount::project.identity.principalId - resourceId: existingContainerRegistryResourceId - } - } -} - -// Extract resource group name from the existing ACR resource ID -// Resource ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ContainerRegistry/registries/{name} -var existingAcrResourceGroup = hasExistingAcr ? split(existingContainerRegistryResourceId, '/')[4] : '' -var existingAcrName = hasExistingAcr ? last(split(existingContainerRegistryResourceId, '/')) : '' - -// Grant AcrPull role to the AI project's managed identity on the existing ACR -// This allows the hosted agents to pull images from the user-provided registry -// Note: User must have permission to assign roles on the existing ACR (Owner or User Access Administrator) -// Using a module allows scoping to a different resource group if the ACR isn't in the same RG -// Skip if connection already exists (role assignment should already be in place) -module existingAcrRoleAssignment './acr-role-assignment.bicep' = if (hasExistingAcr && !hasExistingAcrConnection) { - name: 'existing-acr-role-assignment' - scope: resourceGroup(existingAcrResourceGroup) - params: { - acrName: existingAcrName - acrResourceId: existingContainerRegistryResourceId - principalId: aiAccount::project.identity.principalId - } -} - -// Bing Search grounding module - deploy if Bing connection is defined in ai.yaml or parameter is enabled -module bingGrounding '../search/bing_grounding.bicep' = if (hasBingConnection) { - name: 'bing-grounding' - params: { - tags: tags - resourceName: 'bing-${resourceToken}' - connectionName: bingConnectionName - aiServicesAccountName: aiAccount.name - aiProjectName: aiAccount::project.name - } -} - -// Bing Custom Search grounding module - deploy if custom Bing connection is defined in ai.yaml or parameter is enabled -module bingCustomGrounding '../search/bing_custom_grounding.bicep' = if (hasBingCustomConnection) { - name: 'bing-custom-grounding' - params: { - tags: tags - resourceName: 'bingcustom-${resourceToken}' - connectionName: bingCustomConnectionName - aiServicesAccountName: aiAccount.name - aiProjectName: aiAccount::project.name - } -} - -// Azure AI Search module - deploy if search connection is defined in ai.yaml -module azureAiSearch '../search/azure_ai_search.bicep' = if (hasSearchConnection) { - name: 'azure-ai-search' - params: { - tags: tags - resourceName: 'search-${resourceToken}' - connectionName: searchConnectionName - storageAccountResourceId: hasStorageConnection ? storage!.outputs.storageAccountId : '' - containerName: 'knowledge' - aiServicesAccountName: aiAccount.name - aiProjectName: aiAccount::project.name - principalId: principalId - principalType: principalType - location: location - } -} - -// Outputs -output AZURE_AI_PROJECT_ENDPOINT string = aiAccount::project.properties.endpoints['AI Foundry API'] -output FOUNDRY_PROJECT_ENDPOINT string = aiAccount::project.properties.endpoints['AI Foundry API'] -output AZURE_OPENAI_ENDPOINT string = aiAccount.properties.endpoints['OpenAI Language Model Instance API'] -output aiServicesEndpoint string = aiAccount.properties.endpoint -output accountId string = aiAccount.id -output projectId string = aiAccount::project.id -output aiServicesAccountName string = aiAccount.name -output aiServicesProjectName string = aiAccount::project.name -output aiServicesPrincipalId string = aiAccount.identity.principalId -output projectName string = aiAccount::project.name -output APPLICATIONINSIGHTS_CONNECTION_STRING string = shouldCreateAppInsights ? applicationInsights.outputs.connectionString : (hasExistingAppInsightsConnectionString ? existingApplicationInsightsConnectionString : '') -output APPLICATIONINSIGHTS_RESOURCE_ID string = shouldCreateAppInsights ? applicationInsights.outputs.id : (hasExistingAppInsightsConnectionString ? existingApplicationInsightsResourceId : '') - -// Connection outputs from the connections array -output connectionIds array = [for (connection, index) in (connections ?? []): { - name: aiConnections[index].outputs.connectionName - id: aiConnections[index].outputs.connectionId -}] - -// Grouped dependent resources outputs -output dependentResources object = { - registry: { - name: hasAcrConnection ? acr!.outputs.containerRegistryName : '' - loginServer: hasAcrConnection ? acr!.outputs.containerRegistryLoginServer : ((hasExistingAcr || hasExistingAcrConnection) ? existingContainerRegistryEndpoint : '') - connectionName: hasAcrConnection ? acr!.outputs.containerRegistryConnectionName : (hasExistingAcrConnection ? existingAcrConnectionName : (hasExistingAcr ? 'acr-${resourceToken}' : '')) - } - bing_grounding: { - name: (hasBingConnection) ? bingGrounding!.outputs.bingGroundingName : '' - connectionName: (hasBingConnection) ? bingGrounding!.outputs.bingGroundingConnectionName : '' - connectionId: (hasBingConnection) ? bingGrounding!.outputs.bingGroundingConnectionId : '' - } - bing_custom_grounding: { - name: (hasBingCustomConnection) ? bingCustomGrounding!.outputs.bingCustomGroundingName : '' - connectionName: (hasBingCustomConnection) ? bingCustomGrounding!.outputs.bingCustomGroundingConnectionName : '' - connectionId: (hasBingCustomConnection) ? bingCustomGrounding!.outputs.bingCustomGroundingConnectionId : '' - } - search: { - serviceName: hasSearchConnection ? azureAiSearch!.outputs.searchServiceName : '' - connectionName: hasSearchConnection ? azureAiSearch!.outputs.searchConnectionName : '' - } - storage: { - accountName: hasStorageConnection ? storage!.outputs.storageAccountName : '' - connectionName: hasStorageConnection ? storage!.outputs.storageConnectionName : '' - } -} - -type deploymentsType = { - @description('Specify the name of cognitive service account deployment.') - name: string - - @description('Required. Properties of Cognitive Services account deployment model.') - model: { - @description('Required. The name of Cognitive Services account deployment model.') - name: string - - @description('Required. The format of Cognitive Services account deployment model.') - format: string - - @description('Required. The version of Cognitive Services account deployment model.') - version: string - } - - @description('The resource model definition representing SKU.') - sku: { - @description('Required. The name of the resource model definition representing SKU.') - name: string - - @description('The capacity of the resource model definition representing SKU.') - capacity: int - } -}[]? - -type dependentResourcesType = { - @description('The type of dependent resource to create') - resource: 'storage' | 'registry' | 'azure_ai_search' | 'bing_grounding' | 'bing_custom_grounding' - - @description('The connection name for this resource') - connectionName: string -}[] diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/connection.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/connection.bicep deleted file mode 100644 index a0872664524..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/connection.bicep +++ /dev/null @@ -1,112 +0,0 @@ -targetScope = 'resourceGroup' - -@description('AI Services account name') -param aiServicesAccountName string - -@description('AI project name') -param aiProjectName string - -// Connection configuration type definition -type ConnectionConfig = { - @description('Name of the connection') - name: string - - @description('Category of the connection (e.g., ContainerRegistry, AzureStorageAccount, CognitiveSearch, AzureOpenAI)') - category: string - - @description('Target endpoint or URL for the connection') - target: string - - @description('Authentication type') - authType: 'AAD' | 'AccessKey' | 'AccountKey' | 'AgenticIdentity' | 'ApiKey' | 'CustomKeys' | 'ManagedIdentity' | 'None' | 'OAuth2' | 'PAT' | 'SAS' | 'ServicePrincipal' | 'UsernamePassword' | 'UserEntraToken' | 'ProjectManagedIdentity' - - @description('Whether the connection is shared to all users (optional, defaults to true)') - isSharedToAll: bool? - - @description('Additional metadata for the connection (optional)') - metadata: object? - - @description('Error message if the connection fails (optional)') - error: string? - - @description('Expiry time for the connection (optional)') - expiryTime: string? - - @description('Private endpoint requirement: Required, NotRequired, or NotApplicable (optional)') - peRequirement: ('NotApplicable' | 'NotRequired' | 'Required')? - - @description('Private endpoint status: Active, Inactive, or NotApplicable (optional)') - peStatus: ('Active' | 'Inactive' | 'NotApplicable')? - - @description('List of users to share the connection with (optional, alternative to isSharedToAll)') - sharedUserList: string[]? - - @description('Whether to use workspace managed identity (optional)') - useWorkspaceManagedIdentity: bool? - - @description('OAuth2 authorization endpoint URL (optional, OAuth2 authType only)') - authorizationUrl: string? - - @description('OAuth2 token endpoint URL (optional, OAuth2 authType only)') - tokenUrl: string? - - @description('OAuth2 refresh token endpoint URL (optional, OAuth2 authType only)') - refreshUrl: string? - - @description('OAuth2 scopes to request (optional, OAuth2 authType only)') - scopes: string[]? - - @description('Token audience for UserEntraToken / AgenticIdentity auth types (optional)') - audience: string? - - @description('Managed connector name for OAuth2 managed connectors (optional)') - connectorName: string? -} - -@description('Connection configuration') -param connectionConfig ConnectionConfig - -@secure() -@description('Credentials for the connection. Kept as a separate @secure parameter to prevent secrets from appearing in deployment logs. Shape depends on authType — e.g. { key: "..." } for ApiKey, { clientId: "...", clientSecret: "..." } for OAuth2/ServicePrincipal.') -param credentials object = {} - - -// Get reference to the AI Services account and project -resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { - name: aiServicesAccountName - - resource project 'projects' existing = { - name: aiProjectName - } -} - -// Create the connection -resource connection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = { - parent: aiAccount::project - name: connectionConfig.name - properties: { - category: connectionConfig.category - target: connectionConfig.target - authType: connectionConfig.authType - isSharedToAll: connectionConfig.?isSharedToAll ?? true - credentials: !empty(credentials) ? credentials : null - metadata: connectionConfig.?metadata - // Only include if they appear in the connectionConfig - ...connectionConfig.?error != null ? { error: connectionConfig.?error } : {} - ...connectionConfig.?expiryTime != null ? { expiryTime: connectionConfig.?expiryTime } : {} - ...connectionConfig.?peRequirement != null ? { peRequirement: connectionConfig.?peRequirement } : {} - ...connectionConfig.?peStatus != null ? { peStatus: connectionConfig.?peStatus } : {} - ...connectionConfig.?sharedUserList != null ? { sharedUserList: connectionConfig.?sharedUserList } : {} - ...connectionConfig.?useWorkspaceManagedIdentity != null ? { useWorkspaceManagedIdentity: connectionConfig.?useWorkspaceManagedIdentity } : {} - ...connectionConfig.?authorizationUrl != null ? { authorizationUrl: connectionConfig.?authorizationUrl } : {} - ...connectionConfig.?tokenUrl != null ? { tokenUrl: connectionConfig.?tokenUrl } : {} - ...connectionConfig.?refreshUrl != null ? { refreshUrl: connectionConfig.?refreshUrl } : {} - ...connectionConfig.?scopes != null ? { scopes: connectionConfig.?scopes } : {} - ...connectionConfig.?audience != null ? { audience: connectionConfig.?audience } : {} - ...connectionConfig.?connectorName != null ? { connectorName: connectionConfig.?connectorName } : {} - } -} - -// Outputs -output connectionName string = connection.name -output connectionId string = connection.id diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/existing-ai-project.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/existing-ai-project.bicep deleted file mode 100644 index 12e5a1217b2..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/ai/existing-ai-project.bicep +++ /dev/null @@ -1,140 +0,0 @@ -targetScope = 'resourceGroup' - -@description('Name of the existing AI Services account') -param aiServicesAccountName string - -@description('Name of the existing AI Foundry project') -param aiFoundryProjectName string - -@description('Existing ACR connection name (already set in the environment)') -param existingAcrConnectionName string = '' - -@description('Existing container registry endpoint (already set in the environment)') -param existingContainerRegistryEndpoint string = '' - -@description('Existing Application Insights connection string (already set in the environment)') -param existingApplicationInsightsConnectionString string = '' - -@description('Existing Application Insights resource ID (already set in the environment)') -param existingApplicationInsightsResourceId string = '' - -@description('Model deployments to create on the existing AI Services account') -param deployments deploymentsType - -@description('List of connections to provision on the existing project') -param connections array = [] - -@secure() -@description('Map of connection name to credentials object. Kept as @secure to prevent secrets from appearing in deployment logs. Example: { "my-conn": { "key": "secret" } }') -param connectionCredentials object = {} - -// Reference the existing account and project — read-only except for the -// additional connections provisioned below from the agent manifest. -resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { - name: aiServicesAccountName - - resource project 'projects' existing = { - name: aiFoundryProjectName - } -} - -// Create model deployments on the existing AI Services account. -// Uses @batchSize(1) to avoid concurrent deployment conflicts (same as ai-project.bicep). -@batchSize(1) -resource seqDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-06-01' = [ - for dep in (deployments ?? []): { - parent: aiAccount - name: dep.name - properties: { - model: dep.model - } - sku: dep.sku - } -] - -// Create additional connections from ai.yaml / agent manifest configuration on -// the existing project. Mirrors the loop in ai-project.bicep so manifest-declared -// connections are provisioned regardless of whether the project itself is new or -// pre-existing. -module aiConnections './connection.bicep' = [for (connection, index) in connections: { - name: 'existing-connection-${connection.name}' - params: { - aiServicesAccountName: aiAccount.name - aiProjectName: aiAccount::project.name - connectionConfig: connection - credentials: connectionCredentials[?connection.name] ?? {} - } -}] - -// Outputs — same shape as ai-project.bicep so main.bicep can use either interchangeably -output AZURE_AI_PROJECT_ENDPOINT string = aiAccount::project.properties.endpoints['AI Foundry API'] -output FOUNDRY_PROJECT_ENDPOINT string = aiAccount::project.properties.endpoints['AI Foundry API'] -output AZURE_OPENAI_ENDPOINT string = aiAccount.properties.endpoints['OpenAI Language Model Instance API'] -output aiServicesEndpoint string = aiAccount.properties.endpoint -output accountId string = aiAccount.id -output projectId string = aiAccount::project.id -output aiServicesAccountName string = aiAccount.name -output aiServicesProjectName string = aiAccount::project.name -output aiServicesPrincipalId string = aiAccount.identity.principalId -output projectName string = aiAccount::project.name -output APPLICATIONINSIGHTS_CONNECTION_STRING string = existingApplicationInsightsConnectionString -output APPLICATIONINSIGHTS_RESOURCE_ID string = existingApplicationInsightsResourceId - -// Empty connection outputs — these are already set in the azd environment from init -// Connection outputs from the connections array (provisioned above) -output connectionIds array = [for (connection, index) in (connections ?? []): { - name: aiConnections[index].outputs.connectionName - id: aiConnections[index].outputs.connectionId -}] - -output dependentResources object = { - registry: { - name: '' - loginServer: existingContainerRegistryEndpoint - connectionName: existingAcrConnectionName - } - bing_grounding: { - name: '' - connectionName: '' - connectionId: '' - } - bing_custom_grounding: { - name: '' - connectionName: '' - connectionId: '' - } - search: { - serviceName: '' - connectionName: '' - } - storage: { - accountName: '' - connectionName: '' - } -} - -type deploymentsType = { - @description('Specify the name of cognitive service account deployment.') - name: string - - @description('Required. Properties of Cognitive Services account deployment model.') - model: { - @description('Required. The name of Cognitive Services account deployment model.') - name: string - - @description('Required. The format of Cognitive Services account deployment model.') - format: string - - @description('Required. The version of Cognitive Services account deployment model.') - version: string - } - - @description('The resource model definition representing SKU.') - sku: { - @description('Required. The name of the resource model definition representing SKU.') - name: string - - @description('The capacity of the resource model definition representing SKU.') - capacity: int - } -}[]? diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/host/acr.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/host/acr.bicep deleted file mode 100644 index f1893d8ff31..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/host/acr.bicep +++ /dev/null @@ -1,88 +0,0 @@ -targetScope = 'resourceGroup' - -@description('The location used for all deployed resources') -param location string = resourceGroup().location - -@description('Tags that will be applied to all resources') -param tags object = {} - -@description('Resource name for the container registry') -param resourceName string - -@description('Id of the user or app to assign application roles') -param principalId string - -@description('Principal type of user or app') -param principalType string - -@description('AI Services account name for the project parent') -param aiServicesAccountName string = '' - -@description('AI project name for creating the connection') -param aiProjectName string = '' - -@description('Name for the AI Foundry ACR connection') -param connectionName string - -// Get reference to the AI Services account and project to access their managed identities -resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - name: aiServicesAccountName - - resource aiProject 'projects' existing = { - name: aiProjectName - } -} - -// Create the Container Registry -module containerRegistry 'br/public:avm/res/container-registry/registry:0.1.1' = { - name: 'registry' - params: { - name: resourceName - location: location - tags: tags - publicNetworkAccess: 'Enabled' - roleAssignments:[ - { - principalId: principalId - principalType: principalType - // Container Registry Tasks Contributor — build images with ACR tasks and push container images - roleDefinitionIdOrName: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'fb382eab-e894-4461-af04-94435c366c3f') - } - // TODO SEPARATELY - { - // the foundry project itself can pull from the ACR - principalId: aiAccount::aiProject.identity.principalId - principalType: 'ServicePrincipal' - roleDefinitionIdOrName: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - } - ] - } -} - -// Create the ACR connection using the centralized connection module -module acrConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - name: 'acr-connection-creation' - params: { - aiServicesAccountName: aiServicesAccountName - aiProjectName: aiProjectName - connectionConfig: { - name: connectionName - category: 'ContainerRegistry' - target: containerRegistry.outputs.loginServer - authType: 'ManagedIdentity' - isSharedToAll: true - metadata: { - ResourceId: containerRegistry.outputs.resourceId - } - } - credentials: { - clientId: aiAccount::aiProject.identity.principalId - resourceId: containerRegistry.outputs.resourceId - } - } -} - -output containerRegistryName string = containerRegistry.outputs.name -output containerRegistryLoginServer string = containerRegistry.outputs.loginServer -output containerRegistryResourceId string = containerRegistry.outputs.resourceId -output containerRegistryConnectionName string = acrConnection.outputs.connectionName diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/applicationinsights-dashboard.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/applicationinsights-dashboard.bicep deleted file mode 100644 index d082e668ed9..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/applicationinsights-dashboard.bicep +++ /dev/null @@ -1,1236 +0,0 @@ -metadata description = 'Creates a dashboard for an Application Insights instance.' -param name string -param applicationInsightsName string -param location string = resourceGroup().location -param tags object = {} - -// 2020-09-01-preview because that is the latest valid version -resource applicationInsightsDashboard 'Microsoft.Portal/dashboards@2020-09-01-preview' = { - name: name - location: location - tags: tags - properties: { - lenses: [ - { - order: 0 - parts: [ - { - position: { - x: 0 - y: 0 - colSpan: 2 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'id' - value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - { - name: 'Version' - value: '1.0' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/AspNetOverviewPinnedPart' - asset: { - idInputName: 'id' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'overview' - } - } - { - position: { - x: 2 - y: 0 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'Version' - value: '1.0' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/ProactiveDetectionAsyncPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'ProactiveDetection' - } - } - { - position: { - x: 3 - y: 0 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'ResourceId' - value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/QuickPulseButtonSmallPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - } - } - { - position: { - x: 4 - y: 0 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'TimeContext' - value: { - durationMs: 86400000 - endTime: null - createdTime: '2018-05-04T01:20:33.345Z' - isInitialTime: true - grain: 1 - useDashboardTimeRange: false - } - } - { - name: 'Version' - value: '1.0' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/AvailabilityNavButtonPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - } - } - { - position: { - x: 5 - y: 0 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'TimeContext' - value: { - durationMs: 86400000 - endTime: null - createdTime: '2018-05-08T18:47:35.237Z' - isInitialTime: true - grain: 1 - useDashboardTimeRange: false - } - } - { - name: 'ConfigurationId' - value: '78ce933e-e864-4b05-a27b-71fd55a6afad' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/AppMapButtonPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - } - } - { - position: { - x: 0 - y: 1 - colSpan: 3 - rowSpan: 1 - } - metadata: { - inputs: [] - type: 'Extension/HubsExtension/PartType/MarkdownPart' - settings: { - content: { - settings: { - content: '# Usage' - title: '' - subtitle: '' - } - } - } - } - } - { - position: { - x: 3 - y: 1 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'TimeContext' - value: { - durationMs: 86400000 - endTime: null - createdTime: '2018-05-04T01:22:35.782Z' - isInitialTime: true - grain: 1 - useDashboardTimeRange: false - } - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/UsageUsersOverviewPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - } - } - { - position: { - x: 4 - y: 1 - colSpan: 3 - rowSpan: 1 - } - metadata: { - inputs: [] - type: 'Extension/HubsExtension/PartType/MarkdownPart' - settings: { - content: { - settings: { - content: '# Reliability' - title: '' - subtitle: '' - } - } - } - } - } - { - position: { - x: 7 - y: 1 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ResourceId' - value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - { - name: 'DataModel' - value: { - version: '1.0.0' - timeContext: { - durationMs: 86400000 - createdTime: '2018-05-04T23:42:40.072Z' - isInitialTime: false - grain: 1 - useDashboardTimeRange: false - } - } - isOptional: true - } - { - name: 'ConfigurationId' - value: '8a02f7bf-ac0f-40e1-afe9-f0e72cfee77f' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/CuratedBladeFailuresPinnedPart' - isAdapter: true - asset: { - idInputName: 'ResourceId' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'failures' - } - } - { - position: { - x: 8 - y: 1 - colSpan: 3 - rowSpan: 1 - } - metadata: { - inputs: [] - type: 'Extension/HubsExtension/PartType/MarkdownPart' - settings: { - content: { - settings: { - content: '# Responsiveness\r\n' - title: '' - subtitle: '' - } - } - } - } - } - { - position: { - x: 11 - y: 1 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ResourceId' - value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - { - name: 'DataModel' - value: { - version: '1.0.0' - timeContext: { - durationMs: 86400000 - createdTime: '2018-05-04T23:43:37.804Z' - isInitialTime: false - grain: 1 - useDashboardTimeRange: false - } - } - isOptional: true - } - { - name: 'ConfigurationId' - value: '2a8ede4f-2bee-4b9c-aed9-2db0e8a01865' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/CuratedBladePerformancePinnedPart' - isAdapter: true - asset: { - idInputName: 'ResourceId' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'performance' - } - } - { - position: { - x: 12 - y: 1 - colSpan: 3 - rowSpan: 1 - } - metadata: { - inputs: [] - type: 'Extension/HubsExtension/PartType/MarkdownPart' - settings: { - content: { - settings: { - content: '# Browser' - title: '' - subtitle: '' - } - } - } - } - } - { - position: { - x: 15 - y: 1 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'MetricsExplorerJsonDefinitionId' - value: 'BrowserPerformanceTimelineMetrics' - } - { - name: 'TimeContext' - value: { - durationMs: 86400000 - createdTime: '2018-05-08T12:16:27.534Z' - isInitialTime: false - grain: 1 - useDashboardTimeRange: false - } - } - { - name: 'CurrentFilter' - value: { - eventTypes: [ - 4 - 1 - 3 - 5 - 2 - 6 - 13 - ] - typeFacets: {} - isPermissive: false - } - } - { - name: 'id' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'Version' - value: '1.0' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/MetricsExplorerBladePinnedPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'browser' - } - } - { - position: { - x: 0 - y: 2 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'sessions/count' - aggregationType: 5 - namespace: 'microsoft.insights/components/kusto' - metricVisualization: { - displayName: 'Sessions' - color: '#47BDF5' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'users/count' - aggregationType: 5 - namespace: 'microsoft.insights/components/kusto' - metricVisualization: { - displayName: 'Users' - color: '#7E58FF' - } - } - ] - title: 'Unique sessions and users' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - openBladeOnClick: { - openBlade: true - destinationBlade: { - extensionName: 'HubsExtension' - bladeName: 'ResourceMenuBlade' - parameters: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - menuid: 'segmentationUsers' - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 4 - y: 2 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'requests/failed' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Failed requests' - color: '#EC008C' - } - } - ] - title: 'Failed requests' - visualization: { - chartType: 3 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - openBladeOnClick: { - openBlade: true - destinationBlade: { - extensionName: 'HubsExtension' - bladeName: 'ResourceMenuBlade' - parameters: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - menuid: 'failures' - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 8 - y: 2 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'requests/duration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Server response time' - color: '#00BCF2' - } - } - ] - title: 'Server response time' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - openBladeOnClick: { - openBlade: true - destinationBlade: { - extensionName: 'HubsExtension' - bladeName: 'ResourceMenuBlade' - parameters: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - menuid: 'performance' - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 12 - y: 2 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'browserTimings/networkDuration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Page load network connect time' - color: '#7E58FF' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'browserTimings/processingDuration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Client processing time' - color: '#44F1C8' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'browserTimings/sendDuration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Send request time' - color: '#EB9371' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'browserTimings/receiveDuration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Receiving response time' - color: '#0672F1' - } - } - ] - title: 'Average page load time breakdown' - visualization: { - chartType: 3 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 0 - y: 5 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'availabilityResults/availabilityPercentage' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Availability' - color: '#47BDF5' - } - } - ] - title: 'Average availability' - visualization: { - chartType: 3 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - openBladeOnClick: { - openBlade: true - destinationBlade: { - extensionName: 'HubsExtension' - bladeName: 'ResourceMenuBlade' - parameters: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - menuid: 'availability' - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 4 - y: 5 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'exceptions/server' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Server exceptions' - color: '#47BDF5' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'dependencies/failed' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Dependency failures' - color: '#7E58FF' - } - } - ] - title: 'Server exceptions and Dependency failures' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 8 - y: 5 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'performanceCounters/processorCpuPercentage' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Processor time' - color: '#47BDF5' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'performanceCounters/processCpuPercentage' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Process CPU' - color: '#7E58FF' - } - } - ] - title: 'Average processor and process CPU utilization' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 12 - y: 5 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'exceptions/browser' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Browser exceptions' - color: '#47BDF5' - } - } - ] - title: 'Browser exceptions' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 0 - y: 8 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'availabilityResults/count' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Availability test results count' - color: '#47BDF5' - } - } - ] - title: 'Availability test results count' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 4 - y: 8 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'performanceCounters/processIOBytesPerSecond' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Process IO rate' - color: '#47BDF5' - } - } - ] - title: 'Average process I/O rate' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 8 - y: 8 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'performanceCounters/memoryAvailableBytes' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Available memory' - color: '#47BDF5' - } - } - ] - title: 'Average available memory' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - ] - } - ] - } -} - -resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { - name: applicationInsightsName -} diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/applicationinsights.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/applicationinsights.bicep deleted file mode 100644 index 73240d1b1c9..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/applicationinsights.bicep +++ /dev/null @@ -1,47 +0,0 @@ -metadata description = 'Creates an Application Insights instance based on an existing Log Analytics workspace.' -param name string -param dashboardName string = '' -param location string = resourceGroup().location -param tags object = {} -param logAnalyticsWorkspaceId string - -@description('Optional. Principal ID of the Foundry Project managed identity to grant Log Analytics Reader.') -param projectMIPrincipalId string = '' - -resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { - name: name - location: location - tags: tags - kind: 'web' - properties: { - Application_Type: 'web' - WorkspaceResourceId: logAnalyticsWorkspaceId - } -} - -module applicationInsightsDashboard 'applicationinsights-dashboard.bicep' = if (!empty(dashboardName)) { - name: 'application-insights-dashboard' - params: { - name: dashboardName - location: location - applicationInsightsName: applicationInsights.name - } -} - -// Log Analytics Reader for the Foundry Project managed identity. -// Required for running evaluations on traces generated by agents. -resource logAnalyticsReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(projectMIPrincipalId)) { - scope: applicationInsights - name: guid(applicationInsights.id, projectMIPrincipalId, '73c42c96-874c-492b-b04d-ab87d138a893') - properties: { - principalId: projectMIPrincipalId - principalType: 'ServicePrincipal' - // Log Analytics Reader - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '73c42c96-874c-492b-b04d-ab87d138a893') - } -} - -output connectionString string = applicationInsights.properties.ConnectionString -output id string = applicationInsights.id -output instrumentationKey string = applicationInsights.properties.InstrumentationKey -output name string = applicationInsights.name diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/loganalytics.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/loganalytics.bicep deleted file mode 100644 index 33f9dc29443..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/monitor/loganalytics.bicep +++ /dev/null @@ -1,22 +0,0 @@ -metadata description = 'Creates a Log Analytics workspace.' -param name string -param location string = resourceGroup().location -param tags object = {} - -resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { - name: name - location: location - tags: tags - properties: any({ - retentionInDays: 30 - features: { - searchVersion: 1 - } - sku: { - name: 'PerGB2018' - } - }) -} - -output id string = logAnalytics.id -output name string = logAnalytics.name diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/azure_ai_search.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/azure_ai_search.bicep deleted file mode 100644 index 7bb8e635002..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/azure_ai_search.bicep +++ /dev/null @@ -1,211 +0,0 @@ -targetScope = 'resourceGroup' - -@description('Tags that will be applied to all resources') -param tags object = {} - -@description('Azure Search resource name') -param resourceName string - -@description('Azure Search SKU name') -param azureSearchSkuName string = 'basic' - -@description('Azure storage account resource ID') -param storageAccountResourceId string - -@description('container name') -param containerName string = 'knowledgebase' - -@description('AI Services account name for the project parent') -param aiServicesAccountName string = '' - -@description('AI project name for creating the connection') -param aiProjectName string = '' - -@description('Id of the user or app to assign application roles') -param principalId string - -@description('Principal type of user or app') -param principalType string - -@description('Name for the AI Foundry search connection') -param connectionName string - -@description('Location for all resources') -param location string = resourceGroup().location - -// Get reference to the AI Services account and project to access their managed identities -resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - name: aiServicesAccountName - - resource aiProject 'projects' existing = { - name: aiProjectName - } -} - -// Azure Search Service -resource searchService 'Microsoft.Search/searchServices@2024-06-01-preview' = { - name: resourceName - location: location - tags: tags - sku: { - name: azureSearchSkuName - } - identity: { - type: 'SystemAssigned' - } - properties: { - replicaCount: 1 - partitionCount: 1 - hostingMode: 'default' - authOptions: { - aadOrApiKey: { - aadAuthFailureMode: 'http401WithBearerChallenge' - } - } - disableLocalAuth: false - encryptionWithCmk: { - enforcement: 'Unspecified' - } - publicNetworkAccess: 'enabled' - } -} - -// Reference to existing Storage Account -resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { - name: last(split(storageAccountResourceId, '/')) -} - -// Reference to existing Blob Service -resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' existing = { - parent: storageAccount - name: 'default' -} - -// Storage Container (create if it doesn't exist) -resource storageContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { - parent: blobService - name: containerName - properties: { - publicAccess: 'None' - } -} - -// RBAC Assignments - -// Search needs to read from Storage -resource searchToStorageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storageAccount.id, searchService.id, 'Storage Blob Data Reader', uniqueString(deployment().name)) - scope: storageAccount - properties: { - // GOOD - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1') // Storage Blob Data Reader - principalId: searchService.identity.principalId - principalType: 'ServicePrincipal' - } -} - -// Search needs OpenAI access (AI Services account) -resource searchToAIServicesRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName)) { - name: guid(aiServicesAccountName, searchService.id, 'Cognitive Services OpenAI User', uniqueString(deployment().name)) - properties: { - // GOOD - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd') // Cognitive Services OpenAI User - principalId: searchService.identity.principalId - principalType: 'ServicePrincipal' - } -} - -// AI Project needs Search access - Service Contributor -resource aiServicesToSearchServiceRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - name: guid(searchService.id, aiServicesAccountName, aiProjectName, 'Search Service Contributor', uniqueString(deployment().name)) - scope: searchService - properties: { - // GOOD - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0') // Search Service Contributor - principalId: aiAccount::aiProject.identity.principalId - principalType: 'ServicePrincipal' - } -} - -// AI Project needs Search access - Index Data Contributor -resource aiServicesToSearchDataRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - name: guid(searchService.id, aiServicesAccountName, aiProjectName, 'Search Index Data Contributor', uniqueString(deployment().name)) - scope: searchService - properties: { - // GOOD - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7') // Search Index Data Contributor - principalId: aiAccount::aiProject.identity.principalId - principalType: 'ServicePrincipal' - } -} - -// User permissions - Search Index Data Contributor -resource userToSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(searchService.id, principalId, 'Search Index Data Contributor', uniqueString(deployment().name)) - scope: searchService - properties: { - // GOOD - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7') // Search Index Data Contributor - principalId: principalId - principalType: principalType - } -} - -// // User permissions - Storage Blob Data Contributor -// resource userToStorageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { -// name: guid(storageAccount.id, principalId, 'Storage Blob Data Contributor', uniqueString(deployment().name)) -// scope: storageAccount -// properties: { -// roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor -// principalId: principalId -// principalType: principalType -// } -// } - -// // Project needs Search access - Index Data Contributor -// resource projectToSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { -// name: guid(searchService.id, aiProjectName, 'Search Index Data Contributor', uniqueString(deployment().name)) -// scope: searchService -// properties: { -// roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7') // Search Index Data Contributor -// principalId: aiAccountPrincipalId // Using AI account principal ID as project identity -// principalType: 'ServicePrincipal' -// } -// } - -// Create the AI Search connection using the centralized connection module -module aiSearchConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - name: 'ai-search-connection-creation' - params: { - aiServicesAccountName: aiServicesAccountName - aiProjectName: aiProjectName - connectionConfig: { - name: connectionName - category: 'CognitiveSearch' - target: 'https://${searchService.name}.search.windows.net' - authType: 'AAD' - isSharedToAll: true - metadata: { - ApiVersion: '2024-07-01' - ResourceId: searchService.id - ApiType: 'Azure' - type: 'azure_ai_search' - } - } - } - dependsOn: [ - aiServicesToSearchDataRoleAssignment - ] -} - -// Outputs -output searchServiceName string = searchService.name -output searchServiceId string = searchService.id -output searchServicePrincipalId string = searchService.identity.principalId -output storageAccountName string = storageAccount.name -output storageAccountId string = storageAccount.id -output containerName string = storageContainer.name -output storageAccountPrincipalId string = storageAccount.identity.principalId -output searchConnectionName string = (!empty(aiServicesAccountName) && !empty(aiProjectName)) ? aiSearchConnection!.outputs.connectionName : '' -output searchConnectionId string = (!empty(aiServicesAccountName) && !empty(aiProjectName)) ? aiSearchConnection!.outputs.connectionId : '' - diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/bing_custom_grounding.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/bing_custom_grounding.bicep deleted file mode 100644 index 1fddea079e2..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/bing_custom_grounding.bicep +++ /dev/null @@ -1,84 +0,0 @@ -targetScope = 'resourceGroup' - -@description('Tags that will be applied to all resources') -param tags object = {} - -@description('Bing custom grounding resource name') -param resourceName string - -@description('AI Services account name for the project parent') -param aiServicesAccountName string = '' - -@description('AI project name for creating the connection') -param aiProjectName string = '' - -@description('Name for the AI Foundry Bing Custom Search connection') -param connectionName string - -// Get reference to the AI Services account and project to access their managed identities -resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - name: aiServicesAccountName - - resource aiProject 'projects' existing = { - name: aiProjectName - } -} - -// Bing Search resource for grounding capability -resource bingCustomSearch 'Microsoft.Bing/accounts@2020-06-10' = { - name: resourceName - location: 'global' - tags: tags - sku: { - name: 'G1' - } - properties: { - statisticsEnabled: false - } - kind: 'Bing.CustomGrounding' -} - -// Role assignment to allow AI project to use Bing Search -resource bingCustomSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - scope: bingCustomSearch - name: guid(subscription().id, resourceGroup().id, 'bing-search-role', aiServicesAccountName, aiProjectName) - properties: { - principalId: aiAccount::aiProject.identity.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908') // Cognitive Services User - } -} - -// Create the Bing Custom Search connection using the centralized connection module -module aiSearchConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - name: 'bing-custom-search-connection-creation' - params: { - aiServicesAccountName: aiServicesAccountName - aiProjectName: aiProjectName - connectionConfig: { - name: connectionName - category: 'GroundingWithCustomSearch' - target: bingCustomSearch.properties.endpoint - authType: 'ApiKey' - isSharedToAll: true - metadata: { - Location: 'global' - ResourceId: bingCustomSearch.id - ApiType: 'Azure' - type: 'bing_custom_search' - } - } - credentials: { - key: bingCustomSearch.listKeys().key1 - } - } - dependsOn: [ - bingCustomSearchRoleAssignment - ] -} - -// Outputs -output bingCustomGroundingName string = bingCustomSearch.name -output bingCustomGroundingConnectionName string = aiSearchConnection.outputs.connectionName -output bingCustomGroundingResourceId string = bingCustomSearch.id -output bingCustomGroundingConnectionId string = aiSearchConnection.outputs.connectionId diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/bing_grounding.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/bing_grounding.bicep deleted file mode 100644 index 20ea5e9f160..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/search/bing_grounding.bicep +++ /dev/null @@ -1,83 +0,0 @@ -targetScope = 'resourceGroup' - -@description('Tags that will be applied to all resources') -param tags object = {} - -@description('Bing grounding resource name') -param resourceName string - -@description('AI Services account name for the project parent') -param aiServicesAccountName string = '' - -@description('AI project name for creating the connection') -param aiProjectName string = '' - -@description('Name for the AI Foundry Bing Search connection') -param connectionName string - -// Get reference to the AI Services account and project to access their managed identities -resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - name: aiServicesAccountName - - resource aiProject 'projects' existing = { - name: aiProjectName - } -} - -// Bing Search resource for grounding capability -resource bingSearch 'Microsoft.Bing/accounts@2020-06-10' = { - name: resourceName - location: 'global' - tags: tags - sku: { - name: 'G1' - } - properties: { - statisticsEnabled: false - } - kind: 'Bing.Grounding' -} - -// Role assignment to allow AI project to use Bing Search -resource bingSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - scope: bingSearch - name: guid(subscription().id, resourceGroup().id, 'bing-search-role', aiServicesAccountName, aiProjectName) - properties: { - principalId: aiAccount::aiProject.identity.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908') // Cognitive Services User - } -} - -// Create the Bing Search connection using the centralized connection module -module bingSearchConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - name: 'bing-search-connection-creation' - params: { - aiServicesAccountName: aiServicesAccountName - aiProjectName: aiProjectName - connectionConfig: { - name: connectionName - category: 'GroundingWithBingSearch' - target: bingSearch.properties.endpoint - authType: 'ApiKey' - isSharedToAll: true - metadata: { - Location: 'global' - ResourceId: bingSearch.id - ApiType: 'Azure' - type: 'bing_grounding' - } - } - credentials: { - key: bingSearch.listKeys().key1 - } - } - dependsOn: [ - bingSearchRoleAssignment - ] -} - -output bingGroundingName string = bingSearch.name -output bingGroundingConnectionName string = bingSearchConnection.outputs.connectionName -output bingGroundingResourceId string = bingSearch.id -output bingGroundingConnectionId string = bingSearchConnection.outputs.connectionId diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/storage/storage.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/storage/storage.bicep deleted file mode 100644 index 18d9535dcd0..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/core/storage/storage.bicep +++ /dev/null @@ -1,113 +0,0 @@ -targetScope = 'resourceGroup' - -@description('The location used for all deployed resources') -param location string = resourceGroup().location - -@description('Tags that will be applied to all resources') -param tags object = {} - -@description('Storage account resource name') -param resourceName string - -@description('Id of the user or app to assign application roles') -param principalId string - -@description('Principal type of user or app') -param principalType string - -@description('AI Services account name for the project parent') -param aiServicesAccountName string = '' - -@description('AI project name for creating the connection') -param aiProjectName string = '' - -@description('Name for the AI Foundry storage connection') -param connectionName string - -// Storage Account for the AI Services account -resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { - name: resourceName - location: location - tags: tags - sku: { - name: 'Standard_LRS' - } - kind: 'StorageV2' - identity: { - type: 'SystemAssigned' - } - properties: { - supportsHttpsTrafficOnly: true - allowBlobPublicAccess: false - minimumTlsVersion: 'TLS1_2' - accessTier: 'Hot' - encryption: { - services: { - blob: { - enabled: true - } - file: { - enabled: true - } - } - keySource: 'Microsoft.Storage' - } - } -} - -// Get reference to the AI Services account and project to access their managed identities -resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - name: aiServicesAccountName - - resource aiProject 'projects' existing = { - name: aiProjectName - } -} - -// Role assignment for AI Services to access the storage account -resource storageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - name: guid(storageAccount.id, aiAccount.id, 'ai-storage-contributor') - scope: storageAccount - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor - principalId: aiAccount::aiProject.identity.principalId - principalType: 'ServicePrincipal' - } -} - -// User permissions - Storage Blob Data Contributor -resource userStorageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storageAccount.id, principalId, 'Storage Blob Data Contributor') - scope: storageAccount - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor - principalId: principalId - principalType: principalType - } -} - -// Create the storage connection using the centralized connection module -module storageConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { - name: 'storage-connection-creation' - params: { - aiServicesAccountName: aiServicesAccountName - aiProjectName: aiProjectName - connectionConfig: { - name: connectionName - category: 'AzureStorageAccount' - target: storageAccount.properties.primaryEndpoints.blob - authType: 'AAD' - isSharedToAll: true - metadata: { - ApiType: 'Azure' - ResourceId: storageAccount.id - location: storageAccount.location - } - } - } -} - -output storageAccountName string = storageAccount.name -output storageAccountId string = storageAccount.id -output storageAccountPrincipalId string = storageAccount.identity.principalId -output storageConnectionName string = storageConnection.outputs.connectionName diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/main.bicep b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/main.bicep deleted file mode 100644 index ed4572c1622..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/main.bicep +++ /dev/null @@ -1,248 +0,0 @@ -targetScope = 'subscription' -// targetScope = 'resourceGroup' - -@minLength(1) -@maxLength(64) -@description('Name of the environment that can be used as part of naming resource convention') -param environmentName string - -@minLength(1) -@maxLength(90) -@description('Name of the resource group to use or create') -param resourceGroupName string = 'rg-${environmentName}' - -// Restricted locations to match list from -// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses?tabs=python-key#region-availability -@minLength(1) -@description('Primary location for all resources') -@allowed([ - 'australiaeast' - 'brazilsouth' - 'canadacentral' - 'canadaeast' - 'eastus' - 'eastus2' - 'francecentral' - 'germanywestcentral' - 'italynorth' - 'japaneast' - 'koreacentral' - 'northcentralus' - 'norwayeast' - 'polandcentral' - 'southafricanorth' - 'southcentralus' - 'southeastasia' - 'southindia' - 'spaincentral' - 'swedencentral' - 'switzerlandnorth' - 'uaenorth' - 'uksouth' - 'westus' - 'westus2' - 'westus3' -]) -param location string - -param aiDeploymentsLocation string = location - -@description('Id of the user or app to assign application roles') -param principalId string - -@description('Principal type of user or app') -param principalType string - -@description('Optional salt to diversify resource names across project recreations') -param resourceTokenSalt string = '' - -@description('Optional. Name of an existing AI Services account within the resource group. If not provided, a new one will be created.') -param aiFoundryResourceName string = '' - -@description('Optional. Name of the AI Foundry project. If not provided, a default name will be used.') -param aiFoundryProjectName string = 'ai-project-${environmentName}' - -@description('List of model deployments') -param aiProjectDeploymentsJson string = '[]' - -@description('List of connections') -param aiProjectConnectionsJson string = '[]' - -@secure() -@description('JSON map of connection name to credentials object. Example: {"my-conn":{"key":"secret"}}') -param aiProjectConnectionCredentialsJson string = '{}' - -@description('List of resources to create and connect to the AI project') -param aiProjectDependentResourcesJson string = '[]' - -var aiProjectDeployments = json(aiProjectDeploymentsJson) -var aiProjectConnections = json(aiProjectConnectionsJson) -var aiProjectConnectionCreds = json(aiProjectConnectionCredentialsJson) -var aiProjectDependentResources = json(aiProjectDependentResourcesJson) - -@description('Enable hosted agent deployment') -param enableHostedAgents bool - -@description('Enable the capability host for supporting BYO storage of agent conversations. When false and hosted agents are enabled, the capability host is not created.') -param enableCapabilityHost bool - -@description('Enable monitoring for the AI project') -param enableMonitoring bool - -@description('When true, skip Foundry project/role/connection provisioning and reference the existing project read-only. Use when pointing at an existing Foundry project via --project-id.') -param useExistingAiProject bool = false - -@description('Optional. Existing container registry resource ID. If provided, no new ACR will be created and a connection to this ACR will be established.') -param existingContainerRegistryResourceId string = '' - -@description('Optional. Existing container registry endpoint (login server). Required if existingContainerRegistryResourceId is provided.') -param existingContainerRegistryEndpoint string = '' - -@description('Optional. Name of an existing ACR connection on the Foundry project. If provided, no new ACR or connection will be created.') -param existingAcrConnectionName string = '' - -@description('Optional. Skip ACR creation entirely (e.g. for code-deploy scenarios where no container registry is needed). Defaults to false for backward compatibility.') -param skipAcr bool = false - -@description('Optional. Existing Application Insights connection string. If provided, a connection will be created but no new App Insights resource.') -param existingApplicationInsightsConnectionString string = '' - -@description('Optional. Existing Application Insights resource ID. Used for connection metadata when providing an existing App Insights.') -param existingApplicationInsightsResourceId string = '' - -@description('Optional. Name of an existing Application Insights connection on the Foundry project. If provided, no new App Insights or connection will be created.') -param existingAppInsightsConnectionName string = '' - -// Tags that should be applied to all resources. -// -// Note that 'azd-service-name' tags should be applied separately to service host resources. -// Example usage: -// tags: union(tags, { 'azd-service-name': }) -var tags = { - 'azd-env-name': environmentName -} - -// Check if resource group exists and create it if it doesn't -resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { - name: resourceGroupName - location: location - tags: tags -} - -// Build dependent resources array conditionally -// Check if ACR already exists in the user-provided array to avoid duplicates -// Also skip if user provided an existing container registry endpoint or connection name -var hasAcr = contains(map(aiProjectDependentResources, r => r.resource), 'registry') -var shouldCreateAcr = !skipAcr && enableHostedAgents && !hasAcr && empty(existingContainerRegistryResourceId) && empty(existingAcrConnectionName) -var dependentResources = shouldCreateAcr ? union(aiProjectDependentResources, [ - { - resource: 'registry' - connectionName: 'acr-${uniqueString(subscription().id, resourceGroupName, location)}' - } -]) : aiProjectDependentResources - -// AI Project module — only when creating new resources -module aiProject 'core/ai/ai-project.bicep' = if (!useExistingAiProject) { - scope: rg - name: 'ai-project' - params: { - tags: tags - location: aiDeploymentsLocation - aiFoundryProjectName: aiFoundryProjectName - principalId: principalId - principalType: principalType - existingAiAccountName: aiFoundryResourceName - deployments: aiProjectDeployments - connections: aiProjectConnections - connectionCredentials: aiProjectConnectionCreds - additionalDependentResources: dependentResources - enableMonitoring: enableMonitoring - enableHostedAgents: enableHostedAgents - enableCapabilityHost: enableCapabilityHost - existingContainerRegistryResourceId: existingContainerRegistryResourceId - existingContainerRegistryEndpoint: existingContainerRegistryEndpoint - existingAcrConnectionName: existingAcrConnectionName - existingApplicationInsightsConnectionString: existingApplicationInsightsConnectionString - existingApplicationInsightsResourceId: existingApplicationInsightsResourceId - existingAppInsightsConnectionName: existingAppInsightsConnectionName - resourceTokenSalt: resourceTokenSalt - } -} - -// Existing project module — read-only reference when reusing an existing Foundry project -module existingAiProject 'core/ai/existing-ai-project.bicep' = if (useExistingAiProject) { - scope: rg - name: 'existing-ai-project' - params: { - aiServicesAccountName: aiFoundryResourceName - aiFoundryProjectName: aiFoundryProjectName - deployments: aiProjectDeployments - existingAcrConnectionName: existingAcrConnectionName - existingContainerRegistryEndpoint: existingContainerRegistryEndpoint - existingApplicationInsightsConnectionString: existingApplicationInsightsConnectionString - existingApplicationInsightsResourceId: existingApplicationInsightsResourceId - connections: aiProjectConnections - connectionCredentials: aiProjectConnectionCreds - } -} - -// ACR for existing project — create when hosted agents need a registry but the existing project has none -var shouldCreateAcrForExistingProject = useExistingAiProject && shouldCreateAcr -var acrConnectionName = 'acr-${uniqueString(subscription().id, resourceGroupName, location)}' - -module acrForExistingProject 'core/host/acr.bicep' = if (shouldCreateAcrForExistingProject) { - scope: rg - name: 'acr-for-existing-project' - params: { - location: location - tags: tags - resourceName: 'cr${uniqueString(subscription().id, resourceGroupName, location)}' - connectionName: acrConnectionName - principalId: principalId - principalType: principalType - aiServicesAccountName: aiFoundryResourceName - aiProjectName: aiFoundryProjectName - } -} - -// Resources -output AZURE_RESOURCE_GROUP string = resourceGroupName -output AZURE_AI_ACCOUNT_ID string = useExistingAiProject ? existingAiProject.outputs.accountId : aiProject.outputs.accountId -output AZURE_AI_PROJECT_ID string = useExistingAiProject ? existingAiProject.outputs.projectId : aiProject.outputs.projectId -output AZURE_AI_FOUNDRY_PROJECT_ID string = useExistingAiProject ? existingAiProject.outputs.projectId : aiProject.outputs.projectId -output AZURE_AI_ACCOUNT_NAME string = useExistingAiProject ? existingAiProject.outputs.aiServicesAccountName : aiProject.outputs.aiServicesAccountName -output AZURE_AI_PROJECT_NAME string = useExistingAiProject ? existingAiProject.outputs.projectName : aiProject.outputs.projectName - -// Endpoints -output AZURE_AI_PROJECT_ENDPOINT string = useExistingAiProject ? existingAiProject.outputs.AZURE_AI_PROJECT_ENDPOINT : aiProject.outputs.AZURE_AI_PROJECT_ENDPOINT -output FOUNDRY_PROJECT_ENDPOINT string = useExistingAiProject ? existingAiProject.outputs.FOUNDRY_PROJECT_ENDPOINT : aiProject.outputs.FOUNDRY_PROJECT_ENDPOINT -output AZURE_OPENAI_ENDPOINT string = useExistingAiProject ? existingAiProject.outputs.AZURE_OPENAI_ENDPOINT : aiProject.outputs.AZURE_OPENAI_ENDPOINT -output APPLICATIONINSIGHTS_CONNECTION_STRING string = useExistingAiProject ? existingAiProject.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING : aiProject.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING -output APPLICATIONINSIGHTS_RESOURCE_ID string = useExistingAiProject ? existingAiProject.outputs.APPLICATIONINSIGHTS_RESOURCE_ID : aiProject.outputs.APPLICATIONINSIGHTS_RESOURCE_ID - -// Dependent Resources and Connections - -// ACR -output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = shouldCreateAcrForExistingProject ? acrForExistingProject.outputs.containerRegistryConnectionName : (useExistingAiProject ? existingAiProject.outputs.dependentResources.registry.connectionName : aiProject.outputs.dependentResources.registry.connectionName) -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = shouldCreateAcrForExistingProject ? acrForExistingProject.outputs.containerRegistryLoginServer : (useExistingAiProject ? existingAiProject.outputs.dependentResources.registry.loginServer : aiProject.outputs.dependentResources.registry.loginServer) - -// Bing Search -output BING_GROUNDING_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_grounding.connectionName : aiProject.outputs.dependentResources.bing_grounding.connectionName -output BING_GROUNDING_RESOURCE_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_grounding.name : aiProject.outputs.dependentResources.bing_grounding.name -output BING_GROUNDING_CONNECTION_ID string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_grounding.connectionId : aiProject.outputs.dependentResources.bing_grounding.connectionId - -// Bing Custom Search -output BING_CUSTOM_GROUNDING_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_custom_grounding.connectionName : aiProject.outputs.dependentResources.bing_custom_grounding.connectionName -output BING_CUSTOM_GROUNDING_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_custom_grounding.name : aiProject.outputs.dependentResources.bing_custom_grounding.name -output BING_CUSTOM_GROUNDING_CONNECTION_ID string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_custom_grounding.connectionId : aiProject.outputs.dependentResources.bing_custom_grounding.connectionId - -// Azure AI Search -output AZURE_AI_SEARCH_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.search.connectionName : aiProject.outputs.dependentResources.search.connectionName -output AZURE_AI_SEARCH_SERVICE_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.search.serviceName : aiProject.outputs.dependentResources.search.serviceName - -// Azure Storage -output AZURE_STORAGE_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.storage.connectionName : aiProject.outputs.dependentResources.storage.connectionName -output AZURE_STORAGE_ACCOUNT_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.storage.accountName : aiProject.outputs.dependentResources.storage.accountName - -// Connections -output AI_PROJECT_CONNECTION_IDS_JSON string = useExistingAiProject ? string(existingAiProject.outputs.connectionIds) : string(aiProject.outputs.connectionIds) diff --git a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/main.parameters.json b/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/main.parameters.json deleted file mode 100644 index 0d0109fe4a8..00000000000 --- a/cli/azd/extensions/azure.ai.agents/my-prompt-agent-0701-02/infra/main.parameters.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "resourceGroupName": { - "value": "${AZURE_RESOURCE_GROUP}" - }, - "environmentName": { - "value": "${AZURE_ENV_NAME}" - }, - "location": { - "value": "${AZURE_LOCATION}" - }, - "aiFoundryResourceName": { - "value": "${AZURE_AI_ACCOUNT_NAME}" - }, - "aiFoundryProjectName": { - "value": "${AZURE_AI_PROJECT_NAME}" - }, - "aiDeploymentsLocation": { - "value": "${AZURE_AI_DEPLOYMENTS_LOCATION}" - }, - "resourceTokenSalt": { - "value": "${AZD_RESOURCE_TOKEN_SALT=}" - }, - "principalId": { - "value": "${AZURE_PRINCIPAL_ID}" - }, - "principalType": { - "value": "${AZURE_PRINCIPAL_TYPE}" - }, - "aiProjectDeploymentsJson": { - "value": "${AI_PROJECT_DEPLOYMENTS=[]}" - }, - "aiProjectConnectionsJson": { - "value": "${AI_PROJECT_CONNECTIONS=[]}" - }, - "aiProjectConnectionCredentialsJson": { - "value": "${AI_PROJECT_CONNECTION_CREDENTIALS}" - }, - "aiProjectDependentResourcesJson": { - "value": "${AI_PROJECT_DEPENDENT_RESOURCES=[]}" - }, - "enableMonitoring": { - "value": "${ENABLE_MONITORING=true}" - }, - "enableHostedAgents": { - "value": "${ENABLE_HOSTED_AGENTS=false}" - }, - "enableCapabilityHost": { - "value": "${ENABLE_CAPABILITY_HOST=true}" - }, - "useExistingAiProject": { - "value": "${USE_EXISTING_AI_PROJECT=false}" - }, - "existingContainerRegistryResourceId": { - "value": "${AZURE_CONTAINER_REGISTRY_RESOURCE_ID=}" - }, - "existingContainerRegistryEndpoint": { - "value": "${AZURE_CONTAINER_REGISTRY_ENDPOINT=}" - }, - "existingAcrConnectionName": { - "value": "${AZURE_AI_PROJECT_ACR_CONNECTION_NAME=}" - }, - "skipAcr": { - "value": "${AZD_AGENT_SKIP_ACR=false}" - }, - "existingApplicationInsightsConnectionString": { - "value": "${APPLICATIONINSIGHTS_CONNECTION_STRING=}" - }, - "existingApplicationInsightsResourceId": { - "value": "${APPLICATIONINSIGHTS_RESOURCE_ID=}" - }, - "existingAppInsightsConnectionName": { - "value": "${APPLICATIONINSIGHTS_CONNECTION_NAME=}" - } - } -} diff --git a/docs/specs/managed-harness-agents/generate_getting_started.py b/docs/specs/managed-harness-agents/generate_getting_started.py deleted file mode 100644 index 601ca9f8311..00000000000 --- a/docs/specs/managed-harness-agents/generate_getting_started.py +++ /dev/null @@ -1,239 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -# -# Generates the "Managed (Harness) Agents — Getting Started" Word document. -# PM-oriented framing for early-access customers. -# -# Run: -# python generate_getting_started.py -# -# Output: managed-agents-getting-started.docx in this directory. - -from __future__ import annotations - -import os - -from docx import Document -from docx.enum.style import WD_STYLE_TYPE -from docx.enum.table import WD_ALIGN_VERTICAL -from docx.enum.text import WD_ALIGN_PARAGRAPH -from docx.oxml.ns import qn -from docx.oxml import OxmlElement -from docx.shared import Pt, RGBColor, Inches - - -def _ensure_code_style(doc: Document) -> None: - if "Code Block" in [s.name for s in doc.styles]: - return - style = doc.styles.add_style("Code Block", WD_STYLE_TYPE.PARAGRAPH) - style.font.name = "Consolas" - style.font.size = Pt(9) - style.font.color.rgb = RGBColor(0x1F, 0x1F, 0x1F) - pf = style.paragraph_format - pf.space_before = Pt(4) - pf.space_after = Pt(8) - pf.left_indent = Inches(0.25) - - -def _shade(p, fill="F2F2F2") -> None: - ppr = p._p.get_or_add_pPr() - shd = OxmlElement("w:shd") - shd.set(qn("w:val"), "clear") - shd.set(qn("w:color"), "auto") - shd.set(qn("w:fill"), fill) - ppr.append(shd) - - -def code(doc: Document, text: str) -> None: - p = doc.add_paragraph(style="Code Block") - _shade(p) - p.add_run(text.rstrip("\n")) - - -def inline(p, text: str) -> None: - run = p.add_run(text) - run.font.name = "Consolas" - run.font.size = Pt(10) - - -def para(doc: Document, text: str) -> None: - p = doc.add_paragraph() - rem = text - while rem: - i = rem.find("[[c:") - if i < 0: - p.add_run(rem) - break - p.add_run(rem[:i]) - e = rem.find("]]", i) - inline(p, rem[i + 4 : e]) - rem = rem[e + 2 :] - - -def bullets(doc: Document, items: list[str]) -> None: - for it in items: - p = doc.add_paragraph(style="List Bullet") - rem = it - while rem: - i = rem.find("[[c:") - if i < 0: - p.add_run(rem) - break - p.add_run(rem[:i]) - e = rem.find("]]", i) - inline(p, rem[i + 4 : e]) - rem = rem[e + 2 :] - - -def h1(doc, t): doc.add_heading(t, level=1) -def h2(doc, t): doc.add_heading(t, level=2) - - -def table(doc: Document, header: list[str], rows: list[list[str]]) -> None: - t = doc.add_table(rows=1 + len(rows), cols=len(header)) - t.style = "Light Grid Accent 1" - for i, h in enumerate(header): - t.rows[0].cells[i].paragraphs[0].add_run(h).bold = True - for r, row in enumerate(rows, 1): - for c, v in enumerate(row): - cell = t.rows[r].cells[c] - p = cell.paragraphs[0] - rem = v - while rem: - i = rem.find("[[c:") - if i < 0: - p.add_run(rem) - break - p.add_run(rem[:i]) - e = rem.find("]]", i) - inline(p, rem[i + 4 : e]) - rem = rem[e + 2 :] - cell.vertical_alignment = WD_ALIGN_VERTICAL.TOP - - -def build() -> Document: - doc = Document() - doc.styles["Normal"].font.name = "Calibri" - doc.styles["Normal"].font.size = Pt(11) - _ensure_code_style(doc) - - title = doc.add_paragraph() - r = title.add_run("Managed (Harness) Agents — Getting Started") - r.bold = True - r.font.size = Pt(24) - sub = doc.add_paragraph() - s = sub.add_run("Early-access guide · CLI and SDK · Microsoft Foundry") - s.italic = True - s.font.size = Pt(12) - s.font.color.rgb = RGBColor(0x4A, 0x4A, 0x4A) - meta = doc.add_paragraph() - meta.add_run("Status: Preview Audience: early-access customers Updated: June 2026").italic = True - doc.add_paragraph() - - h1(doc, "Why managed agents") - para(doc, - "A managed agent lets you ship a working AI agent by declaring just two things: a model and " - "instructions. Microsoft Foundry provisions and runs the Brain+Hand sandbox for you — there is no " - "container to build, no service code to host, and no infrastructure to manage. You go from idea to a " - "deployed, callable agent in minutes.") - bullets(doc, [ - "Time-to-first-agent measured in minutes, not days.", - "No Dockerfile, no servers, no scaling decisions — the platform owns the runtime.", - "One agent, two front doors: create with the CLI or the SDK; both target the same Foundry project.", - "Standard OpenAI-shape Responses API for invocation, so existing tooling fits.", - ]) - - h1(doc, "What you'll need") - bullets(doc, [ - "An Azure subscription and a Foundry project (a [[c:CognitiveServices/accounts/projects]] resource).", - "A model deployment in that project (e.g. [[c:gpt-4.1-mini]]).", - "Sign-in via [[c:azd auth login]] / [[c:az login]].", - "Project endpoint [[c:AZURE_AI_PROJECT_ENDPOINT]] = https://.services.ai.azure.com/api/projects/", - "Model name [[c:AZURE_AI_MODEL_DEPLOYMENT_NAME]] = e.g. gpt-4.1-mini", - ]) - - h1(doc, "Option A — azd CLI (fastest path)") - h2(doc, "1. Install") - code(doc, - "winget install microsoft.azd\n" - "azd extension install microsoft.azd.extensions\n" - "azd extension source add --name MHA-dev --type url " - "--location https://raw.githubusercontent.com/kshitij-microsoft/azure-dev/" - "refs/heads/kchawla/azd-managed-harness/cli/azd/extensions/registry.json\n" - "azd extension install azure.ai.agents --source MHA-dev\n" - "azd auth login") - h2(doc, "2. Create") - para(doc, "Choose Prompt agent, pick your subscription and Foundry project, choose a model, and name it.") - code(doc, "azd ai agent init") - h2(doc, "3. Deploy and use") - code(doc, "azd up\nazd ai agent list\nazd ai agent show\nazd ai agent invoke \"hello, what is your name?\"") - para(doc, "`azd down` removes the agent with the project resources.") - - h1(doc, "Option B — Python SDK") - h2(doc, "1. Install") - code(doc, - "pip install azure-ai-projects==2.3.0a20260625001 " - "--extra-index-url https://pkgs.dev.azure.com/azure-sdk/public/_packaging/" - "azure-sdk-for-python/pypi/simple\n" - "pip install azure-identity python-dotenv") - h2(doc, "2. Create a managed agent") - code(doc, - "from azure.identity import DefaultAzureCredential\n" - "from azure.ai.projects import AIProjectClient\n" - "from azure.ai.projects.models import PromptAgentDefinition, AgentHarness\n\n" - "client = AIProjectClient(endpoint=endpoint, credential=DefaultAzureCredential(), allow_preview=True)\n\n" - "client.agents.create_version(\n" - " agent_name=\"my-managed-agent\",\n" - " definition=PromptAgentDefinition(\n" - " model=model_name,\n" - " instructions=\"You are a helpful assistant.\",\n" - " harness=AgentHarness.GHCP,\n" - " ),\n" - ")") - h2(doc, "3. Invoke") - code(doc, - "openai_client = client.get_openai_client()\n" - "response = openai_client.responses.create(\n" - " input=[{\"role\": \"user\", \"content\": \"Generate python to print the OS and run it.\"}],\n" - " store=False,\n" - " extra_body={\"agent_reference\": {\"name\": \"my-managed-agent\", \"version\": \"1\", " - "\"type\": \"agent_reference\"}},\n" - ")") - - h1(doc, "What a response looks like") - para(doc, "Invocations stream Server-Sent Events from the project data-plane. The Brain plans the turn and " - "the Hand sandbox runs any tools/code; only [[c:output_text.delta]] events carry visible text.") - code(doc, - "POST .../api/projects//openai/v1/responses\n" - "x-agent-session-id: ses_...\n\n" - "event: response.created\n" - "event: response.output_text.delta\n" - "event: response.completed") - - h1(doc, "CLI vs SDK at a glance") - table(doc, - ["Task", "CLI", "SDK"], - [ - ["Create", "azd ai agent init + azd up", "create_version(..., harness=GHCP)"], - ["Invoke", "azd ai agent invoke", "responses.create(agent_reference)"], - ["List / show", "azd ai agent list / show", "agents.list / get_version"], - ["Tear down", "azd down", "delete on the project"], - ]) - - h1(doc, "Recommended next steps") - bullets(doc, [ - "Pick the path that matches the customer: CLI for hands-on demos, SDK for app integration.", - "Both write to the same Foundry project — an agent made via SDK shows up in the CLI and vice versa.", - "Share feedback on time-to-first-agent and any blocking errors during the bug bash.", - ]) - return doc - - -def main() -> None: - out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "managed-agents-getting-started.docx") - build().save(out) - print(f"Wrote {out}") - - -if __name__ == "__main__": - main() diff --git a/docs/specs/managed-harness-agents/generate_spec.py b/docs/specs/managed-harness-agents/generate_spec.py deleted file mode 100644 index 10a70f8aa2c..00000000000 --- a/docs/specs/managed-harness-agents/generate_spec.py +++ /dev/null @@ -1,863 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -# -# Generates the "Managed (Harness) Agents in azd" Word document spec. -# -# Run: -# python generate_spec.py -# -# Output: spec.docx in the same directory. - -from __future__ import annotations - -import os -from dataclasses import dataclass - -from docx import Document -from docx.enum.style import WD_STYLE_TYPE -from docx.enum.table import WD_ALIGN_VERTICAL -from docx.enum.text import WD_ALIGN_PARAGRAPH -from docx.oxml.ns import qn -from docx.oxml import OxmlElement -from docx.shared import Pt, RGBColor, Inches - - -# ----------------------------- styling helpers ----------------------------- - - -def _ensure_code_style(doc: Document) -> None: - """Create a "Code Block" character style (Consolas, dark gray).""" - styles = doc.styles - if "Code Block" in [s.name for s in styles]: - return - style = styles.add_style("Code Block", WD_STYLE_TYPE.PARAGRAPH) - font = style.font - font.name = "Consolas" - font.size = Pt(9) - font.color.rgb = RGBColor(0x1F, 0x1F, 0x1F) - pf = style.paragraph_format - pf.space_before = Pt(4) - pf.space_after = Pt(8) - pf.left_indent = Inches(0.25) - - -def _ensure_inline_code_style(doc: Document) -> None: - styles = doc.styles - if "InlineCode" in [s.name for s in styles]: - return - style = styles.add_style("InlineCode", WD_STYLE_TYPE.CHARACTER) - style.font.name = "Consolas" - style.font.size = Pt(10) - - -def _shade_paragraph(paragraph, fill_hex: str = "F2F2F2") -> None: - """Apply a background fill to a paragraph (for code blocks).""" - p_pr = paragraph._p.get_or_add_pPr() - shd = OxmlElement("w:shd") - shd.set(qn("w:val"), "clear") - shd.set(qn("w:color"), "auto") - shd.set(qn("w:fill"), fill_hex) - p_pr.append(shd) - - -def add_code_block(doc: Document, code: str, language: str | None = None) -> None: - para = doc.add_paragraph(style="Code Block") - _shade_paragraph(para) - para.add_run(code.rstrip("\n")) - - -def add_inline_code(paragraph, text: str) -> None: - run = paragraph.add_run(text) - run.font.name = "Consolas" - run.font.size = Pt(10) - - -def add_para(doc: Document, text: str) -> None: - """Add a normal paragraph. Use [[code:foo]] to render inline code spans.""" - para = doc.add_paragraph() - remaining = text - while remaining: - idx = remaining.find("[[code:") - if idx == -1: - para.add_run(remaining) - break - para.add_run(remaining[:idx]) - end = remaining.find("]]", idx) - if end == -1: - para.add_run(remaining[idx:]) - break - add_inline_code(para, remaining[idx + 7 : end]) - remaining = remaining[end + 2 :] - - -def add_bullets(doc: Document, items: list[str]) -> None: - for item in items: - para = doc.add_paragraph(style="List Bullet") - remaining = item - while remaining: - idx = remaining.find("[[code:") - if idx == -1: - para.add_run(remaining) - break - para.add_run(remaining[:idx]) - end = remaining.find("]]", idx) - if end == -1: - para.add_run(remaining[idx:]) - break - add_inline_code(para, remaining[idx + 7 : end]) - remaining = remaining[end + 2 :] - - -def add_h1(doc: Document, text: str) -> None: - para = doc.add_heading(text, level=1) - para.paragraph_format.space_before = Pt(18) - - -def add_h2(doc: Document, text: str) -> None: - doc.add_heading(text, level=2) - - -def add_h3(doc: Document, text: str) -> None: - doc.add_heading(text, level=3) - - -def add_table(doc: Document, header: list[str], rows: list[list[str]]) -> None: - table = doc.add_table(rows=1 + len(rows), cols=len(header)) - table.style = "Light Grid Accent 1" - hdr_cells = table.rows[0].cells - for i, h in enumerate(header): - hdr_cells[i].text = "" - run = hdr_cells[i].paragraphs[0].add_run(h) - run.bold = True - for r, row in enumerate(rows, start=1): - for c, val in enumerate(row): - cell = table.rows[r].cells[c] - cell.text = "" - para = cell.paragraphs[0] - # Honor inline code in cells. - remaining = val - while remaining: - idx = remaining.find("[[code:") - if idx == -1: - para.add_run(remaining) - break - para.add_run(remaining[:idx]) - end = remaining.find("]]", idx) - if end == -1: - para.add_run(remaining[idx:]) - break - add_inline_code(para, remaining[idx + 7 : end]) - remaining = remaining[end + 2 :] - cell.vertical_alignment = WD_ALIGN_VERTICAL.TOP - - -# ----------------------------- content builders --------------------------- - - -def build_title(doc: Document) -> None: - title = doc.add_paragraph() - title.alignment = WD_ALIGN_PARAGRAPH.LEFT - run = title.add_run("Managed (Harness) Agents in azd") - run.bold = True - run.font.size = Pt(26) - - subtitle = doc.add_paragraph() - sub = subtitle.add_run( - "Design spec for the `managed` agent kind in the `azd ai agent` extension" - ) - sub.italic = True - sub.font.size = Pt(12) - sub.font.color.rgb = RGBColor(0x4A, 0x4A, 0x4A) - - meta = doc.add_paragraph() - meta.add_run("Status: Implemented (preview) Owner: azd ai agent team Audience: azd contributors").italic = True - - doc.add_paragraph() # spacer - - -def build_overview(doc: Document) -> None: - add_h1(doc, "Overview") - add_para( - doc, - "The Azure AI Foundry agent platform exposes three first-class agent kinds today: " - "[[code:hosted]] (bring-your-own container/code), [[code:workflow]] (multi-step orchestration), " - "and a new [[code:managed]] kind backed by the Prompt Execution Service (PES) Brain+Hand harness. " - "Managed agents are the simplest shape: the customer declares a model deployment and " - "system instructions; the platform provisions the runtime, executes turns, and persists " - "state. There is no container to build, no Dockerfile, and no service code on the customer side.", - ) - add_para( - doc, - "This spec describes how the [[code:azure.ai.agents]] azd extension implements first-class " - "support for managed agents end-to-end: YAML schema, API wire types, ARM-shaped HTTP client, " - "init scaffolding flow, delete dispatch, and local-development affordances against the " - "[[code:managed-harness]] vienna backend.", - ) - - -def build_goals(doc: Document) -> None: - add_h1(doc, "Goals and Non-Goals") - add_h3(doc, "Goals") - add_bullets( - doc, - [ - "Add a [[code:managed]] discriminator to [[code:AgentKind]] and an accompanying " - "[[code:ManagedAgent]] YAML type that can round-trip through the existing parser.", - "Map the YAML definition to the wire shape ([[code:ManagedAgentDefinition]] + " - "[[code:ManagedEnvironment]] + [[code:ManagedPackages]]) accepted by the v2.0 " - "managed-agents controller.", - "Add an ARM-rooted HTTP client ([[code:ManagedAgentClient]]) covering the lifecycle " - "(create / get / update / delete / list) and the Responses subtree " - "(create / get / cancel / delete).", - "Wire the init flow to ask which agent kind to create as the very first interactive " - "step, and add a [[code:runInitManaged]] path that scaffolds the minimum surface " - "(agent.yaml + azure.yaml service entry) with no Docker, no Language, no src/.", - "Wire the delete flow to detect managed agents via the YAML discriminator and route " - "deletion through [[code:ManagedAgentClient.DeleteAgent]] rather than the hosted path.", - "Allow developer machines to target a local [[code:managed-harness]] backend " - "without an Azure login (env-var override + credential-skip for localhost).", - "Keep all existing hosted-agent code paths byte-identical when [[code:kind]] is not " - "[[code:managed]].", - ], - ) - - add_h3(doc, "Non-Goals (this milestone)") - add_bullets( - doc, - [ - "[[code:azd ai agent show]] / [[code:list]] / [[code:invoke]] wiring for managed agents " - "(designed but deferred — see Open Questions).", - "Hosted versioning semantics for managed agents — the backend does not expose a per-version " - "delete on the v2.0 surface, so [[code:--version]] is rejected with a typed validation error.", - "Surfacing every advanced [[code:ManagedAgentDefinition]] field " - "([[code:structured_inputs]], [[code:files]], full [[code:environment]] block) " - "through YAML — only [[code:model]], [[code:instructions]], [[code:skills]], " - "and [[code:policies]] are exposed today.", - "Automatic ARM workspace discovery from a Foundry project endpoint — callers must " - "set [[code:AZD_MANAGED_AGENT_SUBSCRIPTION_ID]] / [[code:_RESOURCE_GROUP]] / " - "[[code:_WORKSPACE]] explicitly.", - "Schema publication — the [[code:agent.yaml]] schema annotation points at " - "[[code:microsoft/AgentSchema]] and assumes that repo will pick up a " - "[[code:ManagedAgent.yaml]] sibling alongside the existing kinds.", - ], - ) - - -def build_user_stories(doc: Document) -> None: - add_h1(doc, "User Stories") - add_bullets( - doc, - [ - "As a developer I run [[code:azd ai agent init]] in an empty folder, choose " - "\u201cManaged agent\u201d, answer three prompts (name / model / instructions), " - "and end up with an [[code:agent.yaml]] and an [[code:azure.yaml]] service entry " - "ready for [[code:azd deploy]].", - "As a developer I set [[code:FOUNDRY_PROJECT_ENDPOINT]] in my azd environment, run " - "[[code:azd deploy]], and the managed agent is created on Foundry without azd " - "building any container or pushing any code.", - "As a developer I run [[code:azd ai agent delete --service ]] and the " - "extension detects [[code:kind: managed]] in the service's [[code:agent.yaml]] and " - "deletes via the managed lifecycle endpoint instead of the hosted one.", - "As a Foundry platform contributor I run a local [[code:managed-harness]] vienna " - "backend on [[code:http://localhost:5000]], set [[code:AZD_FOUNDRY_ENDPOINT_OVERRIDE=1]] " - "and [[code:AZD_MANAGED_AGENT_BASE_URL=http://localhost:5000]], and exercise the full " - "azd-managed-agent surface against my dev box without an Azure login.", - ], - ) - - -def build_architecture(doc: Document) -> None: - add_h1(doc, "Architecture") - add_para( - doc, - "Managed support is layered onto the existing extension along the same seams as the other " - "agent kinds. The discriminator is [[code:agent.yaml \u2192 kind]]; everything downstream " - "switches on that value.", - ) - add_code_block( - doc, - """\ -azure.ai.agents extension -\u251c\u2500 internal/pkg/agents/agent_yaml/ -\u2502 \u251c\u2500 yaml.go \u2190 ManagedAgent struct + AgentKindManaged constant -\u2502 \u251c\u2500 parse.go \u2190 switch on kind \u2192 unmarshal to ManagedAgent -\u2502 \u251c\u2500 map.go \u2190 CreateManagedAgentAPIRequest(...) \u2192 wire type -\u2502 \u2514\u2500 managed_test.go \u2190 round-trip / validate / dispatcher coverage -\u2502 -\u251c\u2500 internal/pkg/agents/agent_api/ -\u2502 \u251c\u2500 models.go \u2190 ManagedAgentDefinition / ManagedEnvironment / ManagedPackages -\u2502 \u251c\u2500 managed_operations.go\u2190 ManagedAgentClient (lifecycle + responses) + BuildWorkspaceRoutePrefix -\u2502 \u2514\u2500 managed_operations_test.go -\u2502 -\u2514\u2500 internal/cmd/ - \u251c\u2500 init.go \u2190 prompts kind first; routes to runInitManaged when "managed" - \u251c\u2500 init_from_templates_helpers.go \u2190 promptAgentKind() Select - \u251c\u2500 init_managed.go \u2190 scaffolds agent.yaml + adds azure.yaml service entry (no Docker, no src/) - \u251c\u2500 managed_dispatch.go \u2190 isManagedAgentYAML / newManagedAgentClientFromEnv / localhost detection - \u251c\u2500 delete.go \u2190 detects kind \u2192 runManagedDelete via ManagedAgentClient - \u2514\u2500 project_endpoint.go \u2190 AZD_FOUNDRY_ENDPOINT_OVERRIDE bypass for local http:// targets -""", - ) - add_para( - doc, - "The split between [[code:agent_yaml]] and [[code:agent_api]] mirrors the hosted/workflow " - "kinds: [[code:agent_yaml]] is the customer-authored shape, [[code:agent_api]] is the " - "wire shape sent to Foundry. [[code:agent_yaml/map.go]] is the only crossover.", - ) - - -def build_yaml_schema(doc: Document) -> None: - add_h1(doc, "YAML Schema") - add_para( - doc, - "A managed [[code:agent.yaml]] is small by design \u2014 the platform owns the runtime, " - "so the customer-authored shape is just [[code:kind]], [[code:name]], [[code:model]], " - "[[code:instructions]], optional [[code:skills]], and optional [[code:policies]].", - ) - - add_h3(doc, "Minimum example") - add_code_block( - doc, - """\ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ManagedAgent.yaml - -kind: managed -name: customer-support-bot -model: gpt-4.1-mini -instructions: | - You are a helpful customer-support agent. - Always reply in the user's language and cite a knowledge-base - article when you give a factual answer. -""", - ) - - add_h3(doc, "Full example (skills + RAI policy)") - add_code_block( - doc, - """\ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ManagedAgent.yaml - -kind: managed -name: research-assistant -displayName: Research Assistant -description: Summarizes long-form web content with citations. -model: gpt-4.1-mini -instructions: | - You are a research assistant. Cite every source you use. -skills: - - foundry.tools.web_search - - foundry.tools.code_interpreter -policies: - - type: rai_policy - rai_policy_name: /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//raiPolicies/ -""", - ) - - add_h3(doc, "Field reference") - add_table( - doc, - ["Field", "Required", "Type", "Notes"], - [ - ["[[code:kind]]", "yes", "string", "Must be the literal [[code:managed]]."], - ["[[code:name]]", "yes", "string", "Foundry agent identity. Also used as the folder name when scaffolding into a non-empty cwd."], - ["[[code:displayName]]", "no", "string", "Optional human-readable label."], - ["[[code:description]]", "no", "string", "Optional description."], - ["[[code:metadata]]", "no", "map", "Free-form key/value tags. [[code:authors]] is special-cased into a comma-separated string by the mapper."], - ["[[code:model]]", "yes", "string", "Model deployment name (e.g. [[code:gpt-4.1-mini]]). Validated non-empty by [[code:CreateManagedAgentAPIRequest]]."], - ["[[code:instructions]]", "yes", "string", "System/developer message inserted into the model context. Validated non-empty by [[code:CreateManagedAgentAPIRequest]]."], - ["[[code:skills]]", "no", "string[]", "Optional list of Foundry skill identifiers attached to the agent."], - ["[[code:policies]]", "no", "Policy[]", "Optional governance policies. Today only [[code:type: rai_policy]] with an ARM-id-shaped [[code:rai_policy_name]] is supported."], - ], - ) - - add_h3(doc, "Discriminator routing") - add_para( - doc, - "[[code:agent_yaml/parse.go]] switches on the [[code:kind]] field and unmarshals into " - "the matching type. The managed branch is symmetric with [[code:hosted]] and [[code:workflow]]:", - ) - add_code_block( - doc, - """\ -switch agentDef.Kind { -case AgentKindHosted: - // ... ContainerAgent -case AgentKindWorkflow: - // ... Workflow -case AgentKindManaged: - var agent ManagedAgent - if err := yaml.Unmarshal(data, &agent); err != nil { - return nil, fmt.Errorf("failed to unmarshal to ManagedAgent: %w", err) - } - return agent, nil -} -return nil, fmt.Errorf("unrecognized agent kind: %s", agentDef.Kind) -""", - ) - - -def build_wire_contract(doc: Document) -> None: - add_h1(doc, "API Wire Contract") - add_para( - doc, - "The wire shape lives in [[code:internal/pkg/agents/agent_api/models.go]]. It is the " - "JSON body POSTed under the standard [[code:CreateAgentRequest]] envelope, with " - "[[code:Definition]] set to a [[code:ManagedAgentDefinition]].", - ) - - add_h3(doc, "[[code:ManagedAgentDefinition]]") - add_code_block( - doc, - """\ -type ManagedAgentDefinition struct { - AgentDefinition - Model string `json:"model"` - Instructions string `json:"instructions,omitempty"` - Tools []any `json:"tools,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` - Skills []string `json:"skills,omitempty"` - StructuredInputs map[string]any `json:"structured_inputs,omitempty"` - Environment *ManagedEnvironment `json:"environment,omitempty"` - Files map[string]string `json:"files,omitempty"` -} -""", - ) - - add_h3(doc, "[[code:ManagedEnvironment]] / [[code:ManagedPackages]]") - add_code_block( - doc, - """\ -type ManagedPackages struct { - Pip []string `json:"pip,omitempty"` - Apt []string `json:"apt,omitempty"` -} - -type ManagedEnvironment struct { - BaseImage *string `json:"base_image,omitempty"` - Image *string `json:"image,omitempty"` - Packages *ManagedPackages `json:"packages,omitempty"` - CPU *string `json:"cpu,omitempty"` - Memory *string `json:"memory,omitempty"` - EgressPolicy *string `json:"egress_policy,omitempty"` - EnvironmentVariables map[string]string `json:"environment_variables,omitempty"` -} -""", - ) - - add_h3(doc, "Mapping rules ([[code:CreateManagedAgentAPIRequest]])") - add_bullets( - doc, - [ - "[[code:model]] and [[code:instructions]] are validated non-empty; otherwise the call " - "returns [[code:fmt.Errorf]] (\u201cmanaged agent requires a non-empty model/instructions\u201d).", - "[[code:policies]] are run through [[code:mapRaiConfig]] (the same helper used by hosted " - "agents) to produce [[code:AgentDefinition.RaiConfig]] on the wire.", - "[[code:skills]] are cloned ([[code:append([]string(nil), ...]]) so the request does not " - "alias the customer-supplied slice.", - "When a non-nil [[code:AgentBuildConfig]] carries [[code:EnvironmentVariables]], they are " - "copied (via [[code:maps.Clone]]) into [[code:Environment.EnvironmentVariables]] so the " - "Hand sandbox can read them.", - "No [[code:image]], [[code:cpu]], [[code:memory]], or [[code:endpoint]] fields are set " - "from the YAML \u2014 these belong to the hosted/container shape and are not part of the " - "managed customer-authored surface today.", - ], - ) - - -def build_url_surface(doc: Document) -> None: - add_h1(doc, "URL Surface and [[code:ManagedAgentClient]]") - add_para( - doc, - "All managed operations are rooted at an ARM-shaped workspace resource. The client takes a " - "[[code:BaseURL]] (origin) and a [[code:RoutePrefix]] (everything between the origin and " - "[[code:/agents]]) so the same client targets production, an alternate cloud, or a local " - "[[code:managed-harness]] backend without rewiring URL assembly:", - ) - add_code_block( - doc, - """\ -{baseURL}{routePrefix}/agents -{baseURL}{routePrefix}/agents/{name} -{baseURL}{routePrefix}/agents/{name}/openai/responses -{baseURL}{routePrefix}/agents/{name}/openai/responses/{responseId} -{baseURL}{routePrefix}/agents/{name}/openai/responses/{responseId}/cancel -""", - ) - - add_h3(doc, "Production route prefix") - add_para( - doc, - "Built by [[code:BuildWorkspaceRoutePrefix(sub, rg, ws)]]. Each segment is " - "[[code:url.PathEscape]]'d:", - ) - add_code_block( - doc, - """\ -/agents/v2.0/subscriptions//resourceGroups//providers/Microsoft.MachineLearningServices/workspaces/ -""", - ) - - add_h3(doc, "Operations") - add_table( - doc, - ["Method", "URL suffix (after [[code:{baseURL}{routePrefix}/agents]])", "Accepted statuses", "Notes"], - [ - ["[[code:CreateAgent]]", "", "200, 201", "POST. Body is [[code:CreateAgentRequest]] (envelope) with a [[code:ManagedAgentDefinition]]."], - ["[[code:GetAgent]]", "/{name}", "200", "Path-escaped agent name."], - ["[[code:UpdateAgent]]", "/{name}", "200", "POST (intentional; the controller treats POST-to-name as replace)."], - ["[[code:DeleteAgent]]", "/{name}?force=", "200, 204", "Accepts 204 because vienna returns it in some configs; synthesizes [[code:{Deleted: true, Name: name}]] when the body is empty."], - ["[[code:ListAgents]]", "?kind/limit/after/before/order", "200", "All filters optional."], - ["[[code:CreateResponse]]", "/{name}/openai/responses", "200, 201, 202", "Body passes through verbatim \u2014 callers serialize the OpenAI Responses shape themselves; caller-supplied headers are forwarded."], - ["[[code:GetResponse]]", "/{name}/openai/responses/{responseId}", "200", "Raw body + cloned response headers returned."], - ["[[code:CancelResponse]]", "/{name}/openai/responses/{responseId}/cancel", "200, 202", "POST."], - ["[[code:DeleteResponse]]", "/{name}/openai/responses/{responseId}", "200, 204", ""], - ], - ) - - add_h3(doc, "Construction") - add_code_block( - doc, - """\ -client, err := agent_api.NewManagedAgentClient(agent_api.ManagedAgentClientOptions{ - BaseURL: "https://management.azure.com", - RoutePrefix: prefix, // from BuildWorkspaceRoutePrefix(sub, rg, ws) - Credential: cred, // azcore.TokenCredential (nil for unauthenticated localhost) - Scopes: nil, // defaults to {"https://ai.azure.com/.default"} -}) -""", - ) - - add_h3(doc, "Pipeline policies") - add_bullets( - doc, - [ - "[[code:NewMsCorrelationPolicy]] \u2014 attaches [[code:x-ms-correlation-request-id]].", - "[[code:NewUserAgentPolicy]] \u2014 [[code:azd-ext-azure-ai-agents/]].", - "[[code:NewBearerTokenPolicy]] (when [[code:Credential]] is non-nil) using scopes " - "[[code:https://ai.azure.com/.default]] by default. The policy is prepended so it runs " - "before correlation/user-agent.", - "Logging is configured with [[code:IncludeBody=true]] and an allowlist for " - "[[code:X-Ms-Correlation-Request-Id]] / [[code:X-Request-Id]].", - ], - ) - - add_h3(doc, "API version") - add_para( - doc, - "Sent on every request via the [[code:api-version]] query param. The default is " - "[[code:DefaultManagedAgentAPIVersion = \"2025-08-01-preview\"]]. Defining it as an " - "exported constant keeps test wire assertions and command call sites in lockstep when " - "the backend rolls forward.", - ) - - -def build_cli_surface(doc: Document) -> None: - add_h1(doc, "CLI Surface") - - add_h3(doc, "[[code:azd ai agent init]] \u2014 kind selection") - add_para( - doc, - "Before any hosted-specific detection runs (manifest discovery, [[code:--src]] handling, " - "deploy-mode/runtime/entry-point flags), the [[code:init]] command asks the user which " - "kind to scaffold. The prompt is suppressed when any \u201chosted signal\u201d is present " - "on the command line so existing CI scripts stay on the hosted path:", - ) - add_code_block( - doc, - """\ -hostedSignalsPresent := userProvidedManifest || - flags.src != "" || - flags.deployMode != "" || - flags.runtime != "" || - flags.entryPoint != "" -if !hostedSignalsPresent { - kindChoice, kindErr := promptAgentKind(ctx, azdClient, flags.noPrompt) - if kindErr != nil { - return kindErr - } - if kindChoice == AgentKindChoiceManaged { - return runInitManaged(ctx, flags, azdClient) - } -} -""", - ) - add_para( - doc, - "[[code:promptAgentKind]] returns [[code:AgentKindChoiceHosted]] in [[code:--no-prompt]] " - "mode to preserve today's behaviour for callers that do not yet know about the new kind.", - ) - - add_h3(doc, "[[code:runInitManaged]] \u2014 scaffolding flow") - add_bullets( - doc, - [ - "Prompt for [[code:agent name]] (default [[code:my-managed-agent]]; " - "[[code:--agent-name]] required in [[code:--no-prompt]] mode).", - "Prompt for [[code:model deployment]] (default [[code:gpt-4.1-mini]]; " - "[[code:--model]] required in [[code:--no-prompt]] mode).", - "Prompt for [[code:system instructions]] (default placeholder; in [[code:--no-prompt]] " - "mode a self-documenting stub is written so the customer can edit before deploying).", - "Resolve target directory: write at the cwd when empty, otherwise create a sanitized " - "subfolder named after the agent. Refuses to clobber a non-empty existing subfolder.", - "Write [[code:agent.yaml]] with the [[code:yaml-language-server]] schema annotation " - "pointing at the [[code:ManagedAgent.yaml]] schema.", - "Add an [[code:azure.yaml]] service entry via [[code:azdClient.Project().AddService]] " - "with [[code:Host: azure.ai.agent]] and no [[code:Language]] / no [[code:Docker]]. " - "If no [[code:azure.yaml]] exists, return a typed dependency error pointing the user " - "at [[code:azd init]] \u2014 we intentionally do not scaffold a project here.", - "Print a concise summary and a copy-paste-able next-steps block " - "([[code:azd env set FOUNDRY_PROJECT_ENDPOINT \u2026]] \u2192 [[code:azd deploy]]).", - ], - ) - add_para( - doc, - "Critically, [[code:runInitManaged]] does NOT call [[code:ensureProject]] / clone a " - "[[code:azd-ai-starter-basic]] template / scaffold any Bicep. Managed agents assume the " - "Foundry project endpoint already exists \u2014 forcing the user through hosted-shaped " - "infra scaffolding would be misleading.", - ) - - add_h3(doc, "[[code:azd ai agent delete]] \u2014 dispatch") - add_para( - doc, - "The delete command inspects the service's [[code:agent.yaml]] via " - "[[code:isManagedAgentYAML]]. If the discriminator is [[code:managed]], it routes to " - "[[code:DeleteAction.runManagedDelete]]:", - ) - add_bullets( - doc, - [ - "[[code:--version]] is rejected up-front with a typed validation error " - "([[code:CodeInvalidParameter]]) \u2014 managed agents do not expose per-version delete " - "on the v2.0 surface.", - "Constructs a [[code:ManagedAgentClient]] via [[code:newManagedAgentClientFromEnv]].", - "Calls [[code:DeleteAgent(ctx, name, DefaultManagedAgentAPIVersion, force)]] and " - "passes [[code:--force]] through to the [[code:force]] query parameter.", - "On success, best-effort cleans up the matching env-var keys and session state to " - "stay at parity with the hosted delete path.", - "Honors [[code:--output json]] by emitting [[code:DeleteAgentResponse]] directly; " - "the default human-readable output is a one-liner ([[code:Managed agent \"\" deleted.]]).", - ], - ) - - -def build_dispatch_and_envvars(doc: Document) -> None: - add_h1(doc, "Lifecycle Dispatch and Environment Variables") - add_para( - doc, - "All command-side managed plumbing lives in [[code:internal/cmd/managed_dispatch.go]]. " - "It provides three helpers plus a small block of env-var constants:", - ) - - add_h3(doc, "[[code:isManagedAgentYAML(filePath string) (bool, error)]]") - add_para( - doc, - "Reads the file and unmarshals only the [[code:kind]] field via a probe struct. A missing " - "file returns [[code:(false, nil)]] so callers can treat \u201cno agent.yaml present\u201d " - "as \u201cnot a managed agent\u201d rather than an error. A malformed file returns a wrapped " - "[[code:yaml.Unmarshal]] error so the surrounding command can surface it.", - ) - - add_h3(doc, "[[code:newManagedAgentClientFromEnv(ctx) (*ManagedAgentClient, error)]]") - add_bullets( - doc, - [ - "Reads [[code:AZD_MANAGED_AGENT_SUBSCRIPTION_ID]] / [[code:AZD_MANAGED_AGENT_RESOURCE_GROUP]] / " - "[[code:AZD_MANAGED_AGENT_WORKSPACE]] from the process environment.", - "When any of the three is missing or empty, returns a typed validation error " - "([[code:CodeInvalidParameter]]) whose suggestion lists exactly which env vars to set.", - "Builds the route prefix via [[code:BuildWorkspaceRoutePrefix]] (so the same escaping " - "and validation rules apply as the unit tests in [[code:managed_operations_test.go]]).", - "Resolves the base URL from [[code:AZD_MANAGED_AGENT_BASE_URL]] when set, " - "otherwise falls back to [[code:https://management.azure.com]].", - "Resolves the credential via [[code:newAgentCredentialOrNil]]: nil for localhost targets " - "(so devs do not need an Azure login to talk to a local backend), otherwise the standard " - "agent credential. Credential-construction failures are intentionally swallowed and " - "surfaced as nil so the underlying HTTP 401/403 becomes the user-visible error \u2014 " - "that error is more actionable than a generic \u201cfailed to create credential\u201d wrap.", - ], - ) - - add_h3(doc, "[[code:isLocalBackendBaseURL(baseURL)]]") - add_para( - doc, - "Hostname-only prefix check for [[code:localhost]], [[code:127.0.0.1]], and [[code:[::1]]] " - "with either [[code:http://]] or [[code:https://]]. Non-standard ports still match. Used " - "by both the credential decision above and the [[code:project_endpoint]] validator bypass.", - ) - - add_h3(doc, "Environment variable reference") - add_table( - doc, - ["Variable", "Type", "Required", "Used by", "Notes"], - [ - ["[[code:AZD_MANAGED_AGENT_SUBSCRIPTION_ID]]", "string", "yes (managed ops)", "[[code:newManagedAgentClientFromEnv]]", "Azure subscription id hosting the workspace."], - ["[[code:AZD_MANAGED_AGENT_RESOURCE_GROUP]]", "string", "yes (managed ops)", "[[code:newManagedAgentClientFromEnv]]", "Resource group containing the workspace."], - ["[[code:AZD_MANAGED_AGENT_WORKSPACE]]", "string", "yes (managed ops)", "[[code:newManagedAgentClientFromEnv]]", "Workspace name. Path-escaped before being placed in the route prefix."], - ["[[code:AZD_MANAGED_AGENT_BASE_URL]]", "string", "no", "[[code:newManagedAgentClientFromEnv]]", "Overrides the default [[code:https://management.azure.com]] origin. Used to point at a local [[code:managed-harness]] dev backend ([[code:http://localhost:5000]])."], - ["[[code:AZD_FOUNDRY_ENDPOINT_OVERRIDE]]", "presence", "no", "[[code:project_endpoint]] validator", "When set to any non-empty value, the validator accepts [[code:http://]] in addition to [[code:https://]] and skips the Foundry host-suffix check. Intentionally undocumented in user help \u2014 dev/test only."], - ["[[code:FOUNDRY_PROJECT_ENDPOINT]]", "string", "yes (deploy)", "azd environment", "Foundry project endpoint used at deploy/invoke time. Unchanged from the hosted flow \u2014 listed here only because the [[code:runInitManaged]] next-steps block prints how to set it."], - ], - ) - - -def build_local_dev(doc: Document) -> None: - add_h1(doc, "Local Development Against the [[code:managed-harness]] Backend") - add_para( - doc, - "The vienna [[code:managed-harness]] service implements the same v2.0 controller as " - "production and is the recommended local backend. To target it from azd:", - ) - add_code_block( - doc, - """\ -# 1) start managed-harness locally -# (default port 5000; see vienna repo for details) - -# 2) bypass the strict project-endpoint validator so http://localhost is accepted -$Env:AZD_FOUNDRY_ENDPOINT_OVERRIDE = "1" - -# 3) point the managed client at the local origin (anything in {localhost,127.0.0.1,::1} works) -$Env:AZD_MANAGED_AGENT_BASE_URL = "http://localhost:5000" - -# 4) supply the ARM workspace tuple (the harness validates shape but does not call ARM) -$Env:AZD_MANAGED_AGENT_SUBSCRIPTION_ID = "00000000-0000-0000-0000-000000000000" -$Env:AZD_MANAGED_AGENT_RESOURCE_GROUP = "local-rg" -$Env:AZD_MANAGED_AGENT_WORKSPACE = "local-ws" - -# 5) scaffold a managed agent and deploy -azd ai agent init # choose "Managed agent" -azd env set FOUNDRY_PROJECT_ENDPOINT http://localhost:5000/api/projects/local -azd deploy -""", - ) - add_para( - doc, - "Because [[code:isLocalBackendBaseURL]] returns true for any of these origins, the " - "[[code:ManagedAgentClient]] is constructed without a credential and the bearer-token " - "policy is omitted from the pipeline \u2014 no Azure login is required.", - ) - - -def build_testing(doc: Document) -> None: - add_h1(doc, "Testing Strategy") - add_bullets( - doc, - [ - "[[code:agent_yaml/managed_test.go]] \u2014 round-trip YAML \u2192 [[code:ManagedAgent]] " - "\u2192 YAML; validator coverage for missing [[code:model]] / [[code:instructions]]; " - "discriminator routing through [[code:parse.go]].", - "[[code:agent_yaml/map_test.go]] \u2014 [[code:CreateManagedAgentAPIRequest]] populates " - "[[code:ManagedAgentDefinition]] correctly, copies skills/policies, and propagates " - "build-time env vars into [[code:ManagedEnvironment]].", - "[[code:agent_api/managed_operations_test.go]] \u2014 [[code:BuildWorkspaceRoutePrefix]] " - "input validation; [[code:httptest]]-backed coverage for every lifecycle and responses " - "URL (including the 204 path on [[code:DeleteAgent]] and the [[code:force]] query param); " - "header forwarding on [[code:CreateResponse]]; credential-nil pipeline construction.", - "[[code:cmd/project_endpoint_test.go]] \u2014 the [[code:AZD_FOUNDRY_ENDPOINT_OVERRIDE]] " - "bypass accepts [[code:http://]] and skips the host-suffix check only when set.", - "[[code:cmd/managed_dispatch_test.go]] \u2014 [[code:isManagedAgentYAML]] handles " - "missing/malformed/non-managed/managed files; [[code:newManagedAgentClientFromEnv]] " - "returns typed validation errors with actionable suggestions when env vars are missing.", - ], - ) - - -def build_open_questions(doc: Document) -> None: - add_h1(doc, "Open Questions and Future Work") - add_bullets( - doc, - [ - "Wire [[code:azd ai agent show]] / [[code:list]] / [[code:invoke]] for managed agents. " - "Designed but deferred this milestone. [[code:invoke]] in particular wants to lean on " - "[[code:CreateResponse]] + [[code:GetResponse]] streaming.", - "Surface advanced [[code:ManagedAgentDefinition]] fields through YAML: " - "[[code:structured_inputs]], [[code:files]], and the full [[code:environment]] block " - "(image, base_image, packages, cpu/memory, egress_policy). The wire types already accept " - "them; only the [[code:ManagedAgent]] YAML struct intentionally omits them today.", - "Automatic ARM workspace discovery from a Foundry project endpoint. Today the user " - "must set three env vars explicitly. A future iteration could derive the workspace tuple " - "from a project endpoint + credential via a lightweight Foundry control-plane lookup.", - "Hosted versioning parity. Whether the v2.0 controller will gain a per-version delete is " - "an open product question. Today [[code:--version]] is a typed validation error.", - "Publish [[code:ManagedAgent.yaml]] in [[code:microsoft/AgentSchema]] so the schema URL " - "in the [[code:yaml-language-server]] annotation resolves to a real document.", - "Telemetry: emit a [[code:azd.ext.azure.ai.agents.kind]] field on init/delete events " - "so we can measure managed adoption distinct from hosted.", - ], - ) - - -def build_references(doc: Document) -> None: - add_h1(doc, "References") - add_bullets( - doc, - [ - "[[code:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go]] " - "\u2014 [[code:ManagedAgent]] struct, [[code:AgentKindManaged]] constant.", - "[[code:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go]] " - "\u2014 discriminator switch.", - "[[code:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go]] " - "\u2014 [[code:CreateManagedAgentAPIRequest]].", - "[[code:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go]] " - "\u2014 [[code:ManagedAgentDefinition]] / [[code:ManagedEnvironment]] / " - "[[code:ManagedPackages]].", - "[[code:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go]] " - "\u2014 [[code:ManagedAgentClient]] + [[code:BuildWorkspaceRoutePrefix]].", - "[[code:cli/azd/extensions/azure.ai.agents/internal/cmd/init.go]] \u2014 kind prompt insertion site.", - "[[code:cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go]] " - "\u2014 [[code:promptAgentKind]].", - "[[code:cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go]] " - "\u2014 [[code:runInitManaged]] scaffolding.", - "[[code:cli/azd/extensions/azure.ai.agents/internal/cmd/managed_dispatch.go]] " - "\u2014 dispatch helpers + env-var constants.", - "[[code:cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go]] " - "\u2014 [[code:runManagedDelete]].", - "[[code:cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go]] " - "\u2014 [[code:AZD_FOUNDRY_ENDPOINT_OVERRIDE]] bypass.", - ], - ) - - -# ----------------------------- assemble ----------------------------- - - -def build_doc() -> Document: - doc = Document() - - # Defaults - style = doc.styles["Normal"] - style.font.name = "Calibri" - style.font.size = Pt(11) - - _ensure_code_style(doc) - _ensure_inline_code_style(doc) - - build_title(doc) - build_overview(doc) - build_goals(doc) - build_user_stories(doc) - build_architecture(doc) - build_yaml_schema(doc) - build_wire_contract(doc) - build_url_surface(doc) - build_cli_surface(doc) - build_dispatch_and_envvars(doc) - build_local_dev(doc) - build_testing(doc) - build_open_questions(doc) - build_references(doc) - - return doc - - -def main() -> None: - out_dir = os.path.dirname(os.path.abspath(__file__)) - out_path = os.path.join(out_dir, "spec.docx") - doc = build_doc() - doc.save(out_path) - print(f"Wrote {out_path}") - - -if __name__ == "__main__": - main() diff --git a/docs/specs/managed-harness-agents/managed-agents-getting-started.docx b/docs/specs/managed-harness-agents/managed-agents-getting-started.docx deleted file mode 100644 index 52af9b51fa049205ad209e8b3683002008961f4e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39145 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 diff --git a/docs/specs/managed-harness-agents/managed-agents-getting-started.md b/docs/specs/managed-harness-agents/managed-agents-getting-started.md deleted file mode 100644 index 9cd800a7355..00000000000 --- a/docs/specs/managed-harness-agents/managed-agents-getting-started.md +++ /dev/null @@ -1,178 +0,0 @@ -# Managed (Harness) Agents — Getting Started - -Audience: early-access customers evaluating managed prompt agents on Microsoft Foundry. This guide shows two ways to create and call a managed agent: the **azd CLI** (`azd ai agent`) and the **Python SDK** (`azure-ai-projects`). - -A managed agent (a "prompt agent" with `harness=ghcp`) declares only a model and instructions. Foundry provisions and runs the Brain+Hand sandbox for you — there is no container to build and no code to host. Agents live on a Foundry project and are invoked through the OpenAI-shape **Responses** API. - ---- - -## Prerequisites - -- An Azure subscription and a Foundry **project** (an `Microsoft.CognitiveServices/accounts/projects` resource, kind `AIServices`). -- A model deployment in that project (e.g. `gpt-4.1-mini`). -- `azd auth login` / `az login` access to the subscription. - -You will need the project endpoint and a model deployment name: - -- `AZURE_AI_PROJECT_ENDPOINT` = `https://.services.ai.azure.com/api/projects/` -- `AZURE_AI_MODEL_DEPLOYMENT_NAME` = e.g. `gpt-4.1-mini` - ---- - -## Option A — azd CLI - -### 1. Install - -```powershell -# Install azd -winget install microsoft.azd - -# Install the azd extensions developer extension -azd extension install microsoft.azd.extensions - -# Add the dev registry for the bug bash -azd extension source add --name MHA-dev --type url --location https://raw.githubusercontent.com/kshitij-microsoft/azure-dev/refs/heads/kchawla/azd-managed-harness/cli/azd/extensions/registry.json - -# Install the agents extension from that registry -azd extension install azure.ai.agents --source MHA-dev - -# Sign in -azd auth login -``` - -### 2. Initialize a managed agent - -```powershell -azd ai agent init -``` - -When prompted, choose **Prompt agent** (managed), pick your subscription and existing Foundry project, choose a model deployment, and name the agent. This scaffolds: - -- `agent.yaml` — `kind: managed`, the model, and the instructions. -- `azure.yaml` — a service entry (`host: azure.ai.agent`) with a `promptAgent` block. - -### 3. Deploy, list, show, invoke - -```powershell -# Provision (if needed) and create the agent on the project -azd up - -# List managed agents on the project -azd ai agent list - -# Show status of the resolved agent -azd ai agent show - -# Send a message -azd ai agent invoke "hello, what is your name?" -``` - -`list`/`show`/`invoke`/`delete` resolve the same Foundry project the agent was created on. `azd down` removes the agent along with the project resources. - ---- - -## Option B — Python SDK - -### 1. Install - -In your virtual environment: - -```powershell -pip install azure-ai-projects==2.3.0a20260625001 --extra-index-url https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple -pip install azure-identity python-dotenv -``` - -Set the endpoint and model (env vars or a `.env` file): - -```text -AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4.1-mini -``` - -### 2. Create the client - -```python -import os -from dotenv import load_dotenv -from azure.identity import DefaultAzureCredential -from azure.ai.projects import AIProjectClient -from azure.ai.projects.models import PromptAgentDefinition, AgentHarness - -load_dotenv() - -endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] -model_name = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] - -credential = DefaultAzureCredential() -project_client = AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) -``` - -### 3. Create a managed agent version - -The `harness=AgentHarness.GHCP` field routes the agent to the managed (GHCP) runtime instead of the default prompt-agent runtime. - -```python -agent_name = "my-managed-agent" - -created = project_client.agents.create_version( - agent_name=agent_name, - definition=PromptAgentDefinition( - model=model_name, - instructions="You are a helpful assistant.", - harness=AgentHarness.GHCP, - ), - description="Prompt agent running on the GHCP managed runtime.", - metadata={"sample": "agent_harness"}, -) -print(created) -``` - -### 4. Invoke via the Responses API - -Reference the agent by name + version through the OpenAI-compatible client: - -```python -openai_client = project_client.get_openai_client() - -response = openai_client.responses.create( - input=[{"role": "user", "content": "Generate the python code to print the OS and execute it."}], - store=False, - extra_body={"agent_reference": {"name": "my-managed-agent", "version": "1", "type": "agent_reference"}}, -) -print(response) -``` - ---- - -## What the response looks like - -Invocations stream Server-Sent Events from the project data-plane: - -```text -POST https://.services.ai.azure.com/api/projects//openai/v1/responses -content-type: text/event-stream -x-agent-session-id: ses_... - -event: response.created -data: {"type":"response.created","response":{"model":"gpt-5.4","status":"in_progress", ...}} -event: response.output_text.delta -data: {"type":"response.output_text.delta","delta":"..."} -... -event: response.completed -data: {"type":"response.completed", ...} -``` - -The Brain plans the turn and the Hand sandbox executes any tools/code; only `response.output_text.delta` events carry user-visible text. The `x-agent-session-id` header identifies the session for follow-up turns. - ---- - -## Quick reference - -| Step | CLI | SDK | -|---|---|---| -| Create agent | `azd ai agent init` + `azd up` | `agents.create_version(..., harness=AgentHarness.GHCP)` | -| Invoke | `azd ai agent invoke "..."` | `openai_client.responses.create(..., extra_body={agent_reference})` | -| List / show | `azd ai agent list` / `show` | `agents.list()` / `agents.get_version(...)` | -| Endpoint | from `azure.yaml` / env | `AZURE_AI_PROJECT_ENDPOINT` | - -Both paths target the same Foundry project; an agent created via the SDK is visible to the CLI and vice versa. diff --git a/docs/specs/managed-harness-agents/spec.docx b/docs/specs/managed-harness-agents/spec.docx deleted file mode 100644 index 97bddbbb884b20d6026a26af4f801ad67edfa6d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 48114 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%0AXeFBV~N6!2~dDu{6urdp*VXu2kvRS++KD z0mwam4)wi*ebp@yOJ(<`7hUM%Q{aqveCk5*&?2j&)3IS{iFFDEN!$yl+#pSZ%)@oK z0;CNj7ILHg7u!{3`E(p|EC}L5XZ4J+nT&xzjLT->OXlb|e%kcyLM>!6GTZH#IVkF( z(}SH{EKj~HmnxoZsGbqW22-?Q!)>z0_VOTQ*>rGniqBH_euMBT+&|XB$zMkNz9oxl z^TiIHcD?QOR9g1ArNjK+?%uhqE|k&c zQ@zWULK&<}in3D^9GaIOsc$clLnsO1Hs}#bJ5o+wXvW8I-{&4m*wG2#>}-LQx5F&B zV;^o;stsb}q^#3Nv&o0-Ed@b>o%wr4F7J9NGI~T&fsUl5_xBFcAd>rcS>$ zbAul&341PSnjTKYb5OuefwxWs z;X0Qsa$b!|J42Xwm0p3yP&G$x#*%u_%si@e@hhYfeOCZH$34@lX@!@-lR_JMtJ@%# zMk5J(<_1(J`o#V<{I96a*V8IRSZO6XcGNC6Yv?$J>{XqxA$uiB#0A2qZTe=-1KFZc zcFp|Rc}E5wAyRj6vu&C$>0D_Jdc>dgw=o*wq6i<2u8_ETE2dMFK>cyO*mn}@gG~t_ zl-614(g;HIJof-yAOD;$?giQMr?dt9F=F5cSA4b9Pr{qzdzz6HGZeng1ue!%*)Xu4 zuztYMU*SV`(h$rY*R@3*!ywy|nJGPWxzY)%* zCT)Wyg;0l8NvilBvCK-AA$fd^g3vyrfxu}d*+2+!bZ9Uw$eGP7@fe$Gs&H3TnHJ|J zl(pepQZxIE!vcr<-OV#s$%+3U=MM)PGL?vuZ5y@H8?~RjGYT0{>&6}{W@gx)g5DhU zAATLwH^NUKxt9BL2Pa~*rUDX#W|Y(Q%_yG&%z7(;D-~NA?vM})pb-dpH_O-W(L`+SqRQ1= zbWi^hoQ8rJDy!1&kJqVwI4&aCcjYXqk7dUAfgmK?{T30p`=?1~S-!z(Bv`kbE;efmEH1T;gI~*8Xx_tw+*r_Vl03}NH{N-m$@;mN<>$Co^A}& zJic&e>D%&T1z*yhSc7$+8Jte)V?>~_RSq7n=65L9tBbjs20^x;A6fx$qFeEIz7{yB z9zr{n2Ohu`SDdXPH*9X@4i5NedLktOjJ%v!2P7A*lhB>?cfACqM5dFvkB{?NYNl_l zKm!y0#r&-N(_{~e@x?VlF(qnGfc3aD1mG|}hWMPE4Q{mkwF*PeN9_)jFXW60AATs4 z`1oiO&~J}iQW#j(wpcK8{|G$h?Y?mpUU3wrqUbx~I&(^nkh#K&E?=|TeSyEw)gc+m z6HZy6x)Z3zzmU|8hx01Zf1B|qVs>P$mLj}B&mAvi%Yv$4@W%1^xS3mIHly#CcZcG3 z5ieW&pE|NELdbn~BC`J>q_TV+Sq@2|YXwQBecS^>Ut54|DEeD?Qgi`6&!7ON@JYnL zZ4QCh$T%bR=T3-j7a?CGf@rcDQV$lX?LOx1V6E66T97SrR#F2a@bpYMl8&ErM5{9wimYs*7@Y*2$e`ofebX{VH_}$9Jp<)oksCi=w$~zM z*w2HM^3?sYQ%4d2Z>laBqKp&&A;nYHBH;QB&7G82}EOPX}>zBF=fqId(u`J+LB5eLj4HcG4p^q@ zWT5@7-VWdIm#_EoS5XrW0$F&vt@6_`4Ab&6HHO?j`qgVc8$X;!2-Q{7#kXtBUoLAR zcmb{$1?n?~WcKqx>Sn{xFK?HhT44#gbF?VnN+WoGpNu(@qd0@_dM7yv`Y{f$S0VJm zD(loH(Rma^&D~gO?@6)iPx8Qv;5XWab~}u+2Pm_OAc-uz z{lR#IXW^6eFQ`WxBb|+@ZV%tjXn7`)y?jK|Hn*k@vD#xk(Lhc{`O1_(5$sq|@<@Q~ zXkbaJ8bNkBbDFtB#_TI*nkfVGhKv8{{hwU~SQHKwpkq zfWS|O%}{c=wN&Uq5U(k%&Z3prSKuaQVZ%*}T~AeUy$Zx9FDUuetzfX@#yFzs-;s6% zmpF|A-oHU~bHZYXQ{SF-zWn&nCI?h{VyUtw2nQY9$|EX&t*8>OWC+zbz8X4v- zh*rJ2=IVi+8V^McJj99`Q#NF@Es7Tx+Z3X?sjUSNLY6eJn`=MVD~h;n0k#B

Uk# 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 E18FY%jQ{`u diff --git a/docs/specs/managed-harness-agents/~$naged-agents-getting-started.docx b/docs/specs/managed-harness-agents/~$naged-agents-getting-started.docx deleted file mode 100644 index 8b4b0a20701c82e281128a56d203679927d290d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 162 zcmd<{F3!j-$;?u4&PXiJNn{`n@G*EZ6fTAf^uQv6EhNvcCp`ObeVmLzp{aWVeX35y4^s2#%zfG(qh>s0K7jRJOBUy From e991e5f704fa3ea2a87d2f0ef7be56d397065cb3 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Wed, 15 Jul 2026 18:51:27 +0530 Subject: [PATCH 06/24] Managed Harness Agents PrPr --- .../extensions/azure.ai.agents/.agentignore | 40 ++++++++ .../extensions/azure.ai.agents/CHANGELOG.md | 3 +- .../cmd/init_from_templates_helpers.go | 5 +- .../internal/cmd/init_managed.go | 20 ++-- .../internal/cmd/prompt_service.go | 12 +-- .../agents/agent_api/managed_operations.go | 22 ++++- .../pkg/agents/agent_yaml/managed_test.go | 94 +++++++++---------- .../internal/pkg/agents/agent_yaml/map.go | 58 ++++++------ .../internal/pkg/agents/agent_yaml/parse.go | 14 +-- .../agents/agent_yaml/prompt_schema_test.go | 24 ++--- .../internal/pkg/agents/agent_yaml/yaml.go | 14 +-- .../pkg/azure/foundry_skills_client.go | 45 +++++++++ .../pkg/azure/foundry_skills_client_test.go | 39 ++++++++ .../pkg/azure/foundry_toolsets_client.go | 48 ++++++++++ .../pkg/azure/foundry_toolsets_client_test.go | 39 ++++++++ .../internal/project/prompt_connections.go | 2 +- .../project/prompt_connections_test.go | 6 +- .../project/prompt_convention_test.go | 16 ++-- .../project/prompt_deployment_test.go | 10 +- .../internal/project/prompt_files.go | 2 +- .../internal/project/prompt_files_test.go | 12 +-- .../internal/project/prompt_graph.go | 17 +++- .../internal/project/prompt_skills.go | 21 ++++- .../internal/project/prompt_skills_test.go | 10 +- .../internal/project/prompt_tools_test.go | 14 +-- .../internal/project/service_target_prompt.go | 94 +++++++++++++++---- 26 files changed, 498 insertions(+), 183 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/.agentignore diff --git a/cli/azd/extensions/azure.ai.agents/.agentignore b/cli/azd/extensions/azure.ai.agents/.agentignore new file mode 100644 index 00000000000..4e8de03ee83 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/.agentignore @@ -0,0 +1,40 @@ +# Files excluded from agent code deployment packaging. +# Uses .gitignore syntax. +# Note: only the root .agentignore is read; subdirectory files are not supported. +# +# To include a file that is excluded by default, use negation: !filename + +# azd tooling files +agent.yaml +agent.manifest.yaml +azure.yaml +.agentignore + +# Security / secrets +.env +.env.* +.azure/ +.git/ + +# Python +__pycache__/ +.venv/ +venv/ +*.pyc +*.pyo +.mypy_cache/ +.pytest_cache/ + +# .NET +bin/ +obj/ +*.user +*.suo +.vs/ + +# Node +node_modules/ + +# Docker (not used in code deploy) +Dockerfile +.dockerignore diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 7c899d02dbe..feeda7d5193 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Prompt (kind: managed) agents now support a convention-over-configuration deploy pipeline. `azd up` resolves an internal dependency graph before publishing the agent and validates the whole graph first so a failure never leaves a half-wired agent: +- Prompt (kind: prompt) agents now support a convention-over-configuration deploy pipeline. `azd up` resolves an internal dependency graph before publishing the agent and validates the whole graph first so a failure never leaves a half-wired agent: - A sibling `instructions.md` supplies the agent's instructions when none are declared inline (inline wins). - A non-empty `files/` folder is uploaded to a vector store and wired into an auto-added `file_search` tool (content-hash dedupe; existing `file_search` tools are merged, not duplicated). - A non-empty `skills/` folder registers each `SKILL.md` bundle into a Foundry toolbox version and attaches its MCP endpoint as an `mcp` tool; an explicit `toolbox:` reference attaches an existing toolbox instead. @@ -10,6 +10,7 @@ - The model deployment is create-if-missing, and container-only fields (`image`, `protocols`, `code_configuration`, …) are rejected for prompt agents. - The manifest parser recognizes `skill` and `file` resource kinds. - `azd ai agent init` now scaffolds the prompt-agent authoring layout: an `instructions.md` sidecar (instructions are written there instead of inline in `agent.yaml`) plus empty `files/` and `skills/` folders so the deploy conventions are discoverable from a fresh init. +- **Breaking:** the `agent.yaml` discriminator for prompt agents is now `kind: prompt` (was `kind: managed`). Existing `agent.yaml` files must be updated; the scaffolded schema annotation now points at `PromptAgent.yaml`. The `--kind managed` init flag value is still accepted as a backward-compatible alias for `--kind prompt`. ## 0.1.41-preview (2026-06-19) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go index e5a98ca7962..f27ac1283b8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go @@ -98,9 +98,8 @@ const ( AgentKindChoiceHosted agentKindChoice = "hosted" // AgentKindChoicePrompt is the "prompt" agent path — the customer declares // model + instructions and the Foundry harness (GHCP) runs Brain+Hand on - // demand. Note: the on-the-wire agent kind for this path is still - // "managed" (see agent_yaml.AgentKindManaged); "prompt" is the - // user-facing choice value only. + // demand. The scaffolded agent.yaml uses kind: prompt (see + // agent_yaml.AgentKindPrompt), matching this choice value exactly. AgentKindChoicePrompt agentKindChoice = "prompt" // AgentKindChoiceManaged is a backward-compatible alias for // AgentKindChoicePrompt accepted on the --kind flag. Prefer "prompt". diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go index 41fd60c4ae0..e5d48fcb34b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go @@ -138,10 +138,10 @@ func runInitManaged( } } - managedAgent := agent_yaml.ManagedAgent{ + promptAgent := agent_yaml.PromptAgent{ AgentDefinition: agent_yaml.AgentDefinition{ Name: agentName, - Kind: agent_yaml.AgentKindManaged, + Kind: agent_yaml.AgentKindPrompt, }, Model: model, // Instructions are written to a sibling instructions.md by the @@ -150,9 +150,9 @@ func runInitManaged( } if strings.TrimSpace(description) != "" { desc := strings.TrimSpace(description) - managedAgent.AgentDefinition.Description = &desc + promptAgent.AgentDefinition.Description = &desc } - if err := writeManagedAgentYAML(serviceRelPath, &managedAgent); err != nil { + if err := writePromptAgentYAML(serviceRelPath, &promptAgent); err != nil { return err } @@ -410,17 +410,17 @@ func promptManagedAgentInstructions( return instructions, nil } -// writeManagedAgentYAML serializes the ManagedAgent and writes it to +// writePromptAgentYAML serializes the PromptAgent and writes it to // /agent.yaml. A schema annotation comment is prepended for editor // validation parity with the hosted agent flow. -func writeManagedAgentYAML(targetDir string, managedAgent *agent_yaml.ManagedAgent) error { - content, err := yaml.Marshal(managedAgent) +func writePromptAgentYAML(targetDir string, promptAgent *agent_yaml.PromptAgent) error { + content, err := yaml.Marshal(promptAgent) if err != nil { - return fmt.Errorf("marshaling managed agent to YAML: %w", err) + return fmt.Errorf("marshaling prompt agent to YAML: %w", err) } annotation := "# yaml-language-server: " + - "$schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ManagedAgent.yaml" + "$schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/PromptAgent.yaml" buf := bytes.NewBufferString(annotation + "\n\n") if _, err := buf.Write(content); err != nil { return fmt.Errorf("preparing agent.yaml file contents: %w", err) @@ -430,7 +430,7 @@ func writeManagedAgentYAML(targetDir string, managedAgent *agent_yaml.ManagedAge if err := os.WriteFile(filePath, buf.Bytes(), osutil.PermissionFile); err != nil { return fmt.Errorf("saving file to %s: %w", filePath, err) } - log.Printf("Wrote managed agent.yaml at %s", filePath) + log.Printf("Wrote prompt agent.yaml at %s", filePath) return nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go index 2ae9bb235d0..114b07aeab2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go @@ -19,12 +19,12 @@ import ( // promptServiceContext carries everything the prompt-agent commands // (show/invoke/list/delete) need to talk to the harness for a resolved -// azure.ai.agent service of kind=managed. +// azure.ai.agent service of kind=prompt. type promptServiceContext struct { ServiceName string ServiceDir string Settings *project.PromptAgentSettings - Agent agent_yaml.ManagedAgent + Agent agent_yaml.PromptAgent } // promptSettingsFromService extracts the prompt-agent harness settings from a @@ -45,7 +45,7 @@ func promptSettingsFromService(svc *azdext.ServiceConfig) (*project.PromptAgentS } // resolvePromptAgentService resolves the named (or sole) azure.ai.agent service -// and, when it is a prompt (kind=managed) agent, returns its harness settings +// and, when it is a prompt (kind=prompt) agent, returns its harness settings // and parsed agent.yaml. The bool is false when the resolved service is NOT a // prompt agent, so callers can fall back to the hosted code path. func resolvePromptAgentService( @@ -97,9 +97,9 @@ func resolvePromptAgentService( pctx.Agent.Name = svc.Name if pctx.ServiceDir != "" { if data, readErr := os.ReadFile(filepath.Join(pctx.ServiceDir, "agent.yaml")); readErr == nil { - var managed agent_yaml.ManagedAgent - if yaml.Unmarshal(data, &managed) == nil && managed.Name != "" { - pctx.Agent = managed + var promptDef agent_yaml.PromptAgent + if yaml.Unmarshal(data, &promptDef) == nil && promptDef.Name != "" { + pctx.Agent = promptDef } } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go index 99dba046a0f..c11188c2ae5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go @@ -276,6 +276,23 @@ func (c *ManagedAgentClient) UpdateAgent( agentName string, request *UpdateAgentRequest, apiVersion string, +) (*AgentObject, error) { + return c.UpdateAgentWithHeaders(ctx, agentName, request, apiVersion, nil) +} + +// UpdateAgentWithHeaders replaces an existing managed agent's definition, +// publishing a new version, and forwards any additional headers (such as the +// x-model-endpoint routing hint) to the request. This is the prompt-agent +// re-deploy path: managed agents are versioned, so posting a new definition to +// an existing agent creates a new version rather than a conflict. +// +// POST {baseURL}{routePrefix}/agents/{name}?api-version= +func (c *ManagedAgentClient) UpdateAgentWithHeaders( + ctx context.Context, + agentName string, + request *UpdateAgentRequest, + apiVersion string, + headers map[string]string, ) (*AgentObject, error) { if strings.TrimSpace(agentName) == "" { return nil, fmt.Errorf("agentName is required") @@ -294,6 +311,9 @@ func (c *ManagedAgentClient) UpdateAgent( if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } + for k, v := range headers { + req.Raw().Header.Set(k, v) + } if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil { return nil, fmt.Errorf("failed to set request body: %w", err) } @@ -304,7 +324,7 @@ func (c *ManagedAgentClient) UpdateAgent( } defer resp.Body.Close() - if !runtime.HasStatusCode(resp, http.StatusOK) { + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { return nil, runtime.NewResponseError(resp) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go index 00b74b35d7b..f41dcec226e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go @@ -13,15 +13,15 @@ import ( "go.yaml.in/yaml/v3" ) -// TestExtractAgentDefinition_Managed_TemplateWrapper verifies the manifest -// parser routes a "managed" kind to a ManagedAgent value with all declared +// TestExtractAgentDefinition_Prompt_TemplateWrapper verifies the manifest +// parser routes a "prompt" kind to a PromptAgent value with all declared // fields preserved. -func TestExtractAgentDefinition_Managed_TemplateWrapper(t *testing.T) { +func TestExtractAgentDefinition_Prompt_TemplateWrapper(t *testing.T) { yamlContent := []byte(` -name: my-managed-manifest +name: my-prompt-manifest template: - kind: managed - name: my-managed + kind: prompt + name: my-prompt model: gpt-4.1-mini instructions: You are a careful assistant. skills: @@ -32,36 +32,36 @@ template: if err != nil { t.Fatalf("ExtractAgentDefinition failed: %v", err) } - managed, ok := agent.(ManagedAgent) + promptDef, ok := agent.(PromptAgent) if !ok { - t.Fatalf("expected ManagedAgent from template wrapper, got %T", agent) + t.Fatalf("expected PromptAgent from template wrapper, got %T", agent) } - if managed.Name != "my-managed" { - t.Errorf("name: got %q, want %q", managed.Name, "my-managed") + if promptDef.Name != "my-prompt" { + t.Errorf("name: got %q, want %q", promptDef.Name, "my-prompt") } - if managed.Kind != AgentKindManaged { - t.Errorf("kind: got %q, want %q", managed.Kind, AgentKindManaged) + if promptDef.Kind != AgentKindPrompt { + t.Errorf("kind: got %q, want %q", promptDef.Kind, AgentKindPrompt) } - if managed.Model != "gpt-4.1-mini" { - t.Errorf("model: got %q, want %q", managed.Model, "gpt-4.1-mini") + if promptDef.Model != "gpt-4.1-mini" { + t.Errorf("model: got %q, want %q", promptDef.Model, "gpt-4.1-mini") } - if managed.Instructions != "You are a careful assistant." { - t.Errorf("instructions: got %q", managed.Instructions) + if promptDef.Instructions != "You are a careful assistant." { + t.Errorf("instructions: got %q", promptDef.Instructions) } - if len(managed.Skills) != 2 { - t.Fatalf("skills: got %d entries, want 2", len(managed.Skills)) + if len(promptDef.Skills) != 2 { + t.Fatalf("skills: got %d entries, want 2", len(promptDef.Skills)) } } -// TestManagedAgent_YAMLRoundTrip verifies a ManagedAgent value round-trips +// TestPromptAgent_YAMLRoundTrip verifies a PromptAgent value round-trips // through yaml.Marshal / yaml.Unmarshal cleanly. This is the path used when // writing agent.yaml from the init scaffolding and later reading it from disk // as a bare AgentDefinition (without the manifest `template:` wrapper). -func TestManagedAgent_YAMLRoundTrip(t *testing.T) { - original := ManagedAgent{ +func TestPromptAgent_YAMLRoundTrip(t *testing.T) { + original := PromptAgent{ AgentDefinition: AgentDefinition{ - Name: "my-managed", - Kind: AgentKindManaged, + Name: "my-prompt", + Kind: AgentKindPrompt, }, Model: "gpt-4.1-mini", Instructions: "Be helpful.", @@ -70,11 +70,11 @@ func TestManagedAgent_YAMLRoundTrip(t *testing.T) { if err != nil { t.Fatalf("marshal: %v", err) } - if !strings.Contains(string(data), "kind: managed") { + if !strings.Contains(string(data), "kind: prompt") { t.Fatalf("marshaled YAML missing kind discriminator:\n%s", data) } - var roundTripped ManagedAgent + var roundTripped PromptAgent if err := yaml.Unmarshal(data, &roundTripped); err != nil { t.Fatalf("unmarshal: %v", err) } @@ -89,11 +89,11 @@ func TestManagedAgent_YAMLRoundTrip(t *testing.T) { } } -// TestValidateAgentDefinition_Managed_RequiresModelAndInstructions ensures the -// validator requires a model for managed agents. Instructions are intentionally +// TestValidateAgentDefinition_Prompt_RequiresModelAndInstructions ensures the +// validator requires a model for prompt agents. Instructions are intentionally // not required inline (they may come from a sibling instructions.md), so an // agent.yaml without inline instructions must still validate here. -func TestValidateAgentDefinition_Managed_RequiresModelAndInstructions(t *testing.T) { +func TestValidateAgentDefinition_Prompt_RequiresModelAndInstructions(t *testing.T) { cases := []struct { name string yamlContent string @@ -104,7 +104,7 @@ func TestValidateAgentDefinition_Managed_RequiresModelAndInstructions(t *testing name: "missing model", yamlContent: ` name: n -kind: managed +kind: prompt instructions: ok `, wantSubstr: "model", @@ -114,7 +114,7 @@ instructions: ok name: "missing inline instructions is allowed (may come from instructions.md)", yamlContent: ` name: n -kind: managed +kind: prompt model: gpt-4.1-mini `, shouldError: false, @@ -123,7 +123,7 @@ model: gpt-4.1-mini name: "valid", yamlContent: ` name: n -kind: managed +kind: prompt model: gpt-4.1-mini instructions: Be helpful. `, @@ -149,21 +149,21 @@ instructions: Be helpful. } } -// TestCreateManagedAgentAPIRequest_SetsHarness verifies the managed create +// TestCreatePromptAgentAPIRequest_SetsHarness verifies the prompt create // request carries the GitHub Copilot harness identifier in the definition. -func TestCreateManagedAgentAPIRequest_SetsHarness(t *testing.T) { - managed := ManagedAgent{ +func TestCreatePromptAgentAPIRequest_SetsHarness(t *testing.T) { + promptDef := PromptAgent{ AgentDefinition: AgentDefinition{ - Kind: AgentKindManaged, + Kind: AgentKindPrompt, Name: "my-agent", }, Model: "gpt-4.1-mini", Instructions: "Be helpful.", } - req, err := CreateManagedAgentAPIRequest(managed, nil) + req, err := CreatePromptAgentAPIRequest(promptDef, nil) if err != nil { - t.Fatalf("CreateManagedAgentAPIRequest: %v", err) + t.Fatalf("CreatePromptAgentAPIRequest: %v", err) } def, ok := req.Definition.(agent_api.ManagedAgentDefinition) @@ -184,13 +184,13 @@ func TestCreateManagedAgentAPIRequest_SetsHarness(t *testing.T) { } } -// TestCreateManagedAgentAPIRequest_ToolsPassthrough verifies that tools, +// TestCreatePromptAgentAPIRequest_ToolsPassthrough verifies that tools, // tool_choice, and structured_inputs authored in agent.yaml flow through // verbatim into the create request definition and are serialized with the // API's snake_case shape. -func TestCreateManagedAgentAPIRequest_ToolsPassthrough(t *testing.T) { +func TestCreatePromptAgentAPIRequest_ToolsPassthrough(t *testing.T) { yamlContent := []byte(` -kind: managed +kind: prompt name: kitchen-sink-agent model: gpt-4o instructions: You are a maximally capable assistant. @@ -229,17 +229,17 @@ tools: - type: toolbox_search_preview `) - var managed ManagedAgent - if err := yaml.Unmarshal(yamlContent, &managed); err != nil { - t.Fatalf("unmarshal managed agent: %v", err) + var promptDef PromptAgent + if err := yaml.Unmarshal(yamlContent, &promptDef); err != nil { + t.Fatalf("unmarshal prompt agent: %v", err) } - if len(managed.Tools) != 7 { - t.Fatalf("tools: got %d entries, want 7", len(managed.Tools)) + if len(promptDef.Tools) != 7 { + t.Fatalf("tools: got %d entries, want 7", len(promptDef.Tools)) } - req, err := CreateManagedAgentAPIRequest(managed, nil) + req, err := CreatePromptAgentAPIRequest(promptDef, nil) if err != nil { - t.Fatalf("CreateManagedAgentAPIRequest: %v", err) + t.Fatalf("CreatePromptAgentAPIRequest: %v", err) } def, ok := req.Definition.(agent_api.ManagedAgentDefinition) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index bdabb491462..f74219c296a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -133,11 +133,11 @@ func CreateAgentAPIRequestFromDefinition(agentTemplate any, options ...AgentBuil case AgentKindHosted: hostedDef := agentTemplate.(ContainerAgent) return CreateHostedAgentAPIRequest(hostedDef, buildConfig) - case AgentKindManaged: - managedDef := agentTemplate.(ManagedAgent) - return CreateManagedAgentAPIRequest(managedDef, buildConfig) + case AgentKindPrompt: + promptDef := agentTemplate.(PromptAgent) + return CreatePromptAgentAPIRequest(promptDef, buildConfig) default: - return nil, fmt.Errorf("unsupported agent kind: %s. Supported kinds are: hosted, managed", agentDef.Kind) + return nil, fmt.Errorf("unsupported agent kind: %s. Supported kinds are: hosted, prompt", agentDef.Kind) } } @@ -450,63 +450,63 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB hostedAgent.AgentEndpoint, hostedAgent.AgentCard) } -// CreateManagedAgentAPIRequest converts a ManagedAgent YAML definition into the -// API CreateAgentRequest expected by the Foundry managed-agent endpoint. +// CreatePromptAgentAPIRequest converts a PromptAgent YAML definition into the +// API CreateAgentRequest expected by the Foundry prompt-agent endpoint. // -// Managed agents are simpler than hosted agents — the customer only declares +// Prompt agents are simpler than hosted agents — the customer only declares // model + instructions (plus optional skills/policies). The platform manages // the Brain+Hand sandbox, so no image/cpu/memory fields are required from the // customer for the minimum case. -func CreateManagedAgentAPIRequest( - managedAgent ManagedAgent, +func CreatePromptAgentAPIRequest( + promptAgent PromptAgent, buildConfig *AgentBuildConfig, ) (*agent_api.CreateAgentRequest, error) { - if strings.TrimSpace(managedAgent.Model) == "" { - return nil, fmt.Errorf("managed agent requires a non-empty model") + if strings.TrimSpace(promptAgent.Model) == "" { + return nil, fmt.Errorf("prompt agent requires a non-empty model") } - if strings.TrimSpace(managedAgent.Instructions) == "" { - return nil, fmt.Errorf("managed agent requires non-empty instructions") + if strings.TrimSpace(promptAgent.Instructions) == "" { + return nil, fmt.Errorf("prompt agent requires non-empty instructions") } - managedDef := agent_api.ManagedAgentDefinition{ + promptDef := agent_api.ManagedAgentDefinition{ AgentDefinition: agent_api.AgentDefinition{ Kind: agent_api.AgentKindManaged, - RaiConfig: mapRaiConfig(managedAgent.Policies), + RaiConfig: mapRaiConfig(promptAgent.Policies), }, - Model: managedAgent.Model, + Model: promptAgent.Model, Harness: agent_api.ManagedAgentHarnessGitHubCopilot, - Instructions: managedAgent.Instructions, + Instructions: promptAgent.Instructions, } - if len(managedAgent.Skills) > 0 { - managedDef.Skills = append([]string(nil), managedAgent.Skills...) + if len(promptAgent.Skills) > 0 { + promptDef.Skills = append([]string(nil), promptAgent.Skills...) } // Tools, tool_choice, and structured_inputs are passed through verbatim so - // authors can express any tool type the managed-agent API accepts without + // authors can express any tool type the prompt-agent API accepts without // this layer having to model each one. The YAML is decoded into // JSON-compatible values (maps/slices/scalars) and re-serialized as-is. - if len(managedAgent.Tools) > 0 { - managedDef.Tools = managedAgent.Tools + if len(promptAgent.Tools) > 0 { + promptDef.Tools = promptAgent.Tools } - if managedAgent.ToolChoice != nil { - managedDef.ToolChoice = managedAgent.ToolChoice + if promptAgent.ToolChoice != nil { + promptDef.ToolChoice = promptAgent.ToolChoice } - if len(managedAgent.StructuredInputs) > 0 { - managedDef.StructuredInputs = managedAgent.StructuredInputs + if len(promptAgent.StructuredInputs) > 0 { + promptDef.StructuredInputs = promptAgent.StructuredInputs } // Build-time environment variables (if supplied) get carried into the // managed environment block so the Hand sandbox can read them. if buildConfig != nil && len(buildConfig.EnvironmentVariables) > 0 { - managedDef.Environment = &agent_api.ManagedEnvironment{ + promptDef.Environment = &agent_api.ManagedEnvironment{ EnvironmentVariables: maps.Clone(buildConfig.EnvironmentVariables), } } - // Managed agents do not have endpoint or agent-card customization at the + // Prompt agents do not have endpoint or agent-card customization at the // YAML layer today, so pass nil for both. - return createAgentAPIRequest(managedAgent.AgentDefinition, managedDef, nil, nil) + return createAgentAPIRequest(promptAgent.AgentDefinition, promptDef, nil, nil) } // createAgentAPIRequest is a helper function to create the final request with common fields. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go index 7523995abb2..ff80d499b1b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go @@ -117,10 +117,10 @@ func ExtractAgentDefinition(manifestYamlContent []byte) (any, error) { agent.AgentDefinition = agentDef return agent, nil - case AgentKindManaged: - var agent ManagedAgent + case AgentKindPrompt: + var agent PromptAgent if err := yaml.Unmarshal(templateBytes, &agent); err != nil { - return nil, fmt.Errorf("failed to unmarshal to ManagedAgent: %w", err) + return nil, fmt.Errorf("failed to unmarshal to PromptAgent: %w", err) } agent.AgentDefinition = agentDef @@ -438,11 +438,11 @@ func ValidateAgentDefinition(templateBytes []byte) error { } else { errors = append(errors, fmt.Sprintf("failed to unmarshal to Workflow: %v", err)) } - case AgentKindManaged: - var agent ManagedAgent + case AgentKindPrompt: + var agent PromptAgent if err := yaml.Unmarshal(templateBytes, &agent); err == nil { if strings.TrimSpace(agent.Model) == "" { - errors = append(errors, "template.model is required for managed agents") + errors = append(errors, "template.model is required for prompt agents") } // Instructions are intentionally NOT required inline here: // prompt agents may supply them via a sibling instructions.md @@ -469,7 +469,7 @@ func ValidateAgentDefinition(templateBytes []byte) error { } } } else { - errors = append(errors, fmt.Sprintf("failed to unmarshal to ManagedAgent: %v", err)) + errors = append(errors, fmt.Sprintf("failed to unmarshal to PromptAgent: %v", err)) } } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go index 7357d7e3fbb..6192ee064d0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go @@ -9,11 +9,11 @@ import ( "go.yaml.in/yaml/v3" ) -// TestManagedAgent_ConnectionsRoundTrip verifies the prompt-agent `connections:` -// block parses into ManagedAgent.Connections and round-trips through YAML. -func TestManagedAgent_ConnectionsRoundTrip(t *testing.T) { +// TestPromptAgent_ConnectionsRoundTrip verifies the prompt-agent `connections:` +// block parses into PromptAgent.Connections and round-trips through YAML. +func TestPromptAgent_ConnectionsRoundTrip(t *testing.T) { yamlContent := []byte(` -kind: managed +kind: prompt name: conn-agent model: gpt-4.1-mini instructions: You are helpful. @@ -30,15 +30,15 @@ connections: provision: true `) - var managed ManagedAgent - if err := yaml.Unmarshal(yamlContent, &managed); err != nil { + var promptDef PromptAgent + if err := yaml.Unmarshal(yamlContent, &promptDef); err != nil { t.Fatalf("unmarshal: %v", err) } - if len(managed.Connections) != 2 { - t.Fatalf("connections: got %d, want 2", len(managed.Connections)) + if len(promptDef.Connections) != 2 { + t.Fatalf("connections: got %d, want 2", len(promptDef.Connections)) } - first := managed.Connections[0] + first := promptDef.Connections[0] if first.Name != "aisearch-conn" || first.Category != "CognitiveSearch" { t.Errorf("first connection: got %+v", first) } @@ -46,7 +46,7 @@ connections: t.Errorf("first connection target/auth: got %+v", first) } - second := managed.Connections[1] + second := promptDef.Connections[1] if second.AuthType != "ApiKey" || !second.Provision { t.Errorf("second connection: got %+v", second) } @@ -55,11 +55,11 @@ connections: } // Round-trip: marshal then unmarshal and confirm the count is preserved. - data, err := yaml.Marshal(managed) + data, err := yaml.Marshal(promptDef) if err != nil { t.Fatalf("marshal: %v", err) } - var again ManagedAgent + var again PromptAgent if err := yaml.Unmarshal(data, &again); err != nil { t.Fatalf("re-unmarshal: %v", err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index 607f67ba5a2..62c4d3a6534 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -16,11 +16,11 @@ type AgentKind string const ( AgentKindHosted AgentKind = "hosted" AgentKindWorkflow AgentKind = "workflow" - // AgentKindManaged is the Foundry "managed" agent kind backed by the + // AgentKindPrompt is the Foundry "prompt" agent kind backed by the // Prompt Execution Service (PES) Brain+Hand sandbox architecture. // Lifecycle and response APIs live behind the same data-plane routes - // as the other Foundry kinds, with a "kind": "managed" discriminator. - AgentKindManaged AgentKind = "managed" + // as the other Foundry kinds, with a "kind": "prompt" discriminator. + AgentKindPrompt AgentKind = "prompt" ) // IsValidAgentKind checks if the provided AgentKind is valid @@ -33,7 +33,7 @@ func ValidAgentKinds() []AgentKind { return []AgentKind{ AgentKindHosted, AgentKindWorkflow, - AgentKindManaged, + AgentKindPrompt, } } @@ -239,14 +239,14 @@ type ContainerAgent struct { Policies []Policy `json:"policies,omitempty" yaml:"policies,omitempty"` } -// ManagedAgent represents a Foundry "managed" agent — a PES (Prompt Execution +// PromptAgent represents a Foundry "prompt" agent — a PES (Prompt Execution // Service) backed agent whose Brain+Hand sandbox is provisioned by the // platform on demand. The customer declares the model and instructions; the // platform manages the runtime, lifecycle, and orchestration. // // Unlike ContainerAgent, the customer does not provide a container image or // code; the only required fields are Model and Instructions. -type ManagedAgent struct { +type PromptAgent struct { AgentDefinition `json:",inline" yaml:",inline"` // Model is the model deployment name to use for this agent (e.g. "gpt-4.1-mini"). @@ -261,7 +261,7 @@ type ManagedAgent struct { Skills []string `json:"skills,omitempty" yaml:"skills,omitempty"` // Tools is an optional list of tool definitions attached to the agent. - // Entries are passed through verbatim to the Foundry managed-agent API, so + // Entries are passed through verbatim to the Foundry prompt-agent API, so // author them using the API's snake_case tool schema. Supported types // include (but are not limited to): function, code_interpreter, file_search, // web_search, image_generation, mcp, azure_ai_search, azure_function, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go index ed0a4888793..8a037f2e51e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go @@ -138,3 +138,48 @@ func (c *FoundrySkillsClient) CreateSkillVersion( } return &result, nil } + +// PromoteSkillVersion updates the skill's default_version, making it the +// version resolved by references that omit an explicit version (including the +// Foundry portal's skill view). Creating a skill version does NOT +// automatically promote it — every version after the first must be promoted +// explicitly for consumers to see it as the active content. +// +// POST {endpoint}/skills/{name}?api-version=v1 +func (c *FoundrySkillsClient) PromoteSkillVersion( + ctx context.Context, + skillName string, + version string, +) error { + targetURL := fmt.Sprintf( + "%s/skills/%s?api-version=%s", + c.endpoint, url.PathEscape(skillName), skillsApiVersion, + ) + + payload, err := json.Marshal(map[string]string{"default_version": version}) + if err != nil { + return fmt.Errorf("marshaling request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPost, targetURL) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", skillsFeatureHeader) + if err := req.SetBody( + streaming.NopCloser(bytes.NewReader(payload)), + "application/json", + ); err != nil { + return fmt.Errorf("setting request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + if !runtime.HasStatusCode(resp, http.StatusOK) { + return runtime.NewResponseError(resp) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go index 17112abceea..ca400690787 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go @@ -68,3 +68,42 @@ func TestCreateSkillVersion_ErrorStatus(t *testing.T) { }) require.Error(t, err) } + +func TestPromoteSkillVersion_RequestShape(t *testing.T) { + var captured *http.Request + var body []byte + + client := newTestSkillsClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { + captured = req + if req.Body != nil { + body, _ = io.ReadAll(req.Body) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"s-1","name":"my-skill","default_version":"1.2.0"}`)), + Header: make(http.Header), + }, nil + }) + + err := client.PromoteSkillVersion(t.Context(), "my-skill", "1.2.0") + require.NoError(t, err) + + require.NotNil(t, captured) + require.Equal(t, http.MethodPost, captured.Method) + require.Equal(t, "/skills/my-skill", captured.URL.EscapedPath()) + require.Equal(t, "api-version="+skillsApiVersion, captured.URL.RawQuery) + require.Equal(t, skillsFeatureHeader, captured.Header.Get("Foundry-Features")) + require.Contains(t, string(body), `"default_version":"1.2.0"`) +} + +func TestPromoteSkillVersion_ErrorStatus(t *testing.T) { + client := newTestSkillsClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"error":"bad"}`)), + Header: make(http.Header), + }, nil + }) + err := client.PromoteSkillVersion(t.Context(), "s", "1.0.0") + require.Error(t, err) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go index 06305965404..249c538c1db 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go @@ -219,3 +219,51 @@ func (c *FoundryToolboxClient) DeleteToolbox( return nil } + +// PromoteToolboxVersion updates the toolbox's default_version, making it the +// version the consumer MCP endpoint (/toolboxes/{name}/mcp) serves. Creating a +// toolbox version does NOT automatically promote it — the Foundry API tracks +// default_version separately, and the first version created for a brand-new +// toolbox is the only one auto-promoted. Every subsequent version must be +// promoted explicitly for consumers (including the Foundry portal skill/tool +// view) to see it. +// +// PATCH {endpoint}/toolboxes/{name}?api-version=v1 +func (c *FoundryToolboxClient) PromoteToolboxVersion( + ctx context.Context, + toolboxName string, + version string, +) error { + targetUrl := fmt.Sprintf( + "%s/toolboxes/%s?api-version=%s", + c.endpoint, url.PathEscape(toolboxName), toolboxesApiVersion, + ) + + payload, err := json.Marshal(map[string]string{"default_version": version}) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPatch, targetUrl) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", toolboxesFeatureHeader) + if err := req.SetBody( + streaming.NopCloser(bytes.NewReader(payload)), + "application/json", + ); err != nil { + return fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return runtime.NewResponseError(resp) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go index d975d1bcbe4..33a74d68198 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go @@ -286,3 +286,42 @@ func TestToolboxClient_PathEscaping_Adversarial(t *testing.T) { }) } } + +func TestPromoteToolboxVersion_RequestShape(t *testing.T) { + var captured *http.Request + var body []byte + + client := newTestToolboxClient("https://example.com", func(req *http.Request) (*http.Response, error) { + captured = req + if req.Body != nil { + body, _ = io.ReadAll(req.Body) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"1","name":"tb","default_version":"v2"}`)), + Header: make(http.Header), + }, nil + }) + + err := client.PromoteToolboxVersion(t.Context(), "tb", "v2") + require.NoError(t, err) + + require.NotNil(t, captured) + require.Equal(t, http.MethodPatch, captured.Method) + require.Equal(t, "/toolboxes/tb", captured.URL.EscapedPath()) + require.Equal(t, "api-version="+toolboxesApiVersion, captured.URL.RawQuery) + require.Equal(t, toolboxesFeatureHeader, captured.Header.Get("Foundry-Features")) + require.Contains(t, string(body), `"default_version":"v2"`) +} + +func TestPromoteToolboxVersion_ErrorStatus(t *testing.T) { + client := newTestToolboxClient("https://example.com", func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"error":"bad"}`)), + Header: make(http.Header), + }, nil + }) + err := client.PromoteToolboxVersion(t.Context(), "tb", "v2") + require.Error(t, err) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go index f26effdfdf0..26a777b46d9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go @@ -225,7 +225,7 @@ func connectionsNode( func assignConnectionRoles( ctx context.Context, resolver connectionResolver, - managed *agent_yaml.ManagedAgent, + managed *agent_yaml.PromptAgent, ) error { byName := map[string]agent_yaml.PromptConnection{} for _, c := range managed.Connections { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go index 4816aa5a0c8..55de625143b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go @@ -132,7 +132,7 @@ func (r *fakeConnectionResolver) AssignRole( } func TestConnectionsNode_CreatesMissingAndAssignsRole(t *testing.T) { - managed := &agent_yaml.ManagedAgent{ + managed := &agent_yaml.PromptAgent{ Model: "m", Instructions: "i", Connections: []agent_yaml.PromptConnection{ @@ -166,7 +166,7 @@ func TestConnectionsNode_CreatesMissingAndAssignsRole(t *testing.T) { } func TestConnectionsNode_UsesExistingNoCreate(t *testing.T) { - managed := &agent_yaml.ManagedAgent{ + managed := &agent_yaml.PromptAgent{ Model: "m", Instructions: "i", Connections: []agent_yaml.PromptConnection{ @@ -187,7 +187,7 @@ func TestConnectionsNode_UsesExistingNoCreate(t *testing.T) { } func TestConnectionsNode_NoneReturnsNil(t *testing.T) { - g := &promptGraph{managed: &agent_yaml.ManagedAgent{}, bindings: map[string]any{}} + g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} node := connectionsNode(g, func() (connectionResolver, error) { return nil, nil }) if node != nil { t.Fatal("expected nil node when no connections declared") diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go index 4f40f461039..05bb1a4b137 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go @@ -34,7 +34,7 @@ func writeAgentYAML(t *testing.T, agentYAML string, instructionsMD *string) *Age func TestLoadPromptDef_InstructionsFileFallback(t *testing.T) { md := "You are a careful assistant.\nAnswer concisely." p := writeAgentYAML(t, ` -kind: managed +kind: prompt name: file-instr model: gpt-4.1-mini `, &md) @@ -53,7 +53,7 @@ model: gpt-4.1-mini func TestLoadPromptDef_InlineWinsOverFile(t *testing.T) { md := "FROM FILE" p := writeAgentYAML(t, ` -kind: managed +kind: prompt name: inline-wins model: gpt-4.1-mini instructions: FROM INLINE @@ -72,7 +72,7 @@ instructions: FROM INLINE // instructions leaves the field empty (graph validation reports the error). func TestLoadPromptDef_NoInstructionsAnywhere(t *testing.T) { p := writeAgentYAML(t, ` -kind: managed +kind: prompt name: no-instr model: gpt-4.1-mini `, nil) @@ -87,13 +87,13 @@ model: gpt-4.1-mini } // TestLoadPromptDef_RejectsContainerFields verifies container-only fields are -// rejected for a prompt (kind: managed) agent. +// rejected for a prompt (kind: prompt) agent. func TestLoadPromptDef_RejectsContainerFields(t *testing.T) { cases := []string{"image", "protocols", "code_configuration", "agent_endpoint"} for _, field := range cases { t.Run(field, func(t *testing.T) { p := writeAgentYAML(t, ` -kind: managed +kind: prompt name: bad model: gpt-4.1-mini instructions: ok @@ -118,21 +118,21 @@ func TestResolvePromptAgentGraph_ValidatesModelAndInstructions(t *testing.T) { p := &AgentServiceTargetProvider{} // Missing model → error. - missingModel := &agent_yaml.ManagedAgent{Instructions: "ok"} + missingModel := &agent_yaml.PromptAgent{Instructions: "ok"} missingModel.Name = "x" if err := p.resolvePromptAgentGraph(t.Context(), missingModel, nil, nil, nil); err == nil { t.Error("expected error when model is empty") } // Missing instructions → error. - missingInstr := &agent_yaml.ManagedAgent{Model: "gpt-4.1-mini"} + missingInstr := &agent_yaml.PromptAgent{Model: "gpt-4.1-mini"} missingInstr.Name = "x" if err := p.resolvePromptAgentGraph(t.Context(), missingInstr, nil, nil, nil); err == nil { t.Error("expected error when instructions are empty") } // Complete → no error. - complete := &agent_yaml.ManagedAgent{Model: "gpt-4.1-mini", Instructions: "ok"} + complete := &agent_yaml.PromptAgent{Model: "gpt-4.1-mini", Instructions: "ok"} complete.Name = "x" if err := p.resolvePromptAgentGraph(t.Context(), complete, nil, nil, nil); err != nil { t.Errorf("unexpected error for complete definition: %v", err) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment_test.go index 8cbd557d951..d06b627fa82 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment_test.go @@ -31,7 +31,7 @@ func (r *fakeDeploymentResolver) Create(context.Context, string) error { } func TestDeploymentNode_CreatesWhenMissing(t *testing.T) { - managed := &agent_yaml.ManagedAgent{Model: "gpt-4.1-mini", Instructions: "i"} + managed := &agent_yaml.PromptAgent{Model: "gpt-4.1-mini", Instructions: "i"} managed.Name = "agent" g := &promptGraph{managed: managed, bindings: map[string]any{}} fake := &fakeDeploymentResolver{exists: false} @@ -52,7 +52,7 @@ func TestDeploymentNode_CreatesWhenMissing(t *testing.T) { } func TestDeploymentNode_SkipsCreateWhenExists(t *testing.T) { - managed := &agent_yaml.ManagedAgent{Model: "gpt-4.1-mini", Instructions: "i"} + managed := &agent_yaml.PromptAgent{Model: "gpt-4.1-mini", Instructions: "i"} managed.Name = "agent" g := &promptGraph{managed: managed, bindings: map[string]any{}} fake := &fakeDeploymentResolver{exists: true} @@ -67,7 +67,7 @@ func TestDeploymentNode_SkipsCreateWhenExists(t *testing.T) { } func TestDeploymentNode_ValidateRejectsBadModelName(t *testing.T) { - managed := &agent_yaml.ManagedAgent{Model: "not a/valid name", Instructions: "i"} + managed := &agent_yaml.PromptAgent{Model: "not a/valid name", Instructions: "i"} managed.Name = "agent" g := &promptGraph{managed: managed, bindings: map[string]any{}} node := deploymentNode(g, func() (deploymentResolver, error) { @@ -84,7 +84,7 @@ func TestDeploymentNode_ValidateRejectsBadModelName(t *testing.T) { func TestGraphResolve_ValidatesAllBeforeAnyMutation(t *testing.T) { resolved := 0 g := &promptGraph{ - managed: &agent_yaml.ManagedAgent{}, + managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}, nodes: []promptNode{ { @@ -120,7 +120,7 @@ func TestGraphResolve_ValidatesAllBeforeAnyMutation(t *testing.T) { func TestGraphResolve_ResolvesInOrderWhenValid(t *testing.T) { var order []promptNodeKind g := &promptGraph{ - managed: &agent_yaml.ManagedAgent{}, + managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}, nodes: []promptNode{ { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go index c3e86dc6daf..09fdd085309 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go @@ -103,7 +103,7 @@ func scanFilesDir(agentDir string) ([]fileEntry, error) { // wired to storeID. If a file_search tool already exists, storeID is merged // into its vector_store_ids (deduped) rather than adding a second tool. The // managed definition is mutated in place. -func injectFileSearchTool(managed *agent_yaml.ManagedAgent, storeID string) { +func injectFileSearchTool(managed *agent_yaml.PromptAgent, storeID string) { if managed == nil || strings.TrimSpace(storeID) == "" { return } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go index 1219b195d29..e0891a2b5da 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go @@ -100,7 +100,7 @@ func TestScanFilesDir_SortedByName(t *testing.T) { } func TestInjectFileSearchTool_AddsWhenAbsent(t *testing.T) { - managed := &agent_yaml.ManagedAgent{} + managed := &agent_yaml.PromptAgent{} injectFileSearchTool(managed, "vs-1") if len(managed.Tools) != 1 { @@ -117,7 +117,7 @@ func TestInjectFileSearchTool_AddsWhenAbsent(t *testing.T) { } func TestInjectFileSearchTool_MergesExisting(t *testing.T) { - managed := &agent_yaml.ManagedAgent{ + managed := &agent_yaml.PromptAgent{ Tools: []any{ map[string]any{ "type": "file_search", @@ -138,7 +138,7 @@ func TestInjectFileSearchTool_MergesExisting(t *testing.T) { } func TestInjectFileSearchTool_NoDuplicateID(t *testing.T) { - managed := &agent_yaml.ManagedAgent{ + managed := &agent_yaml.PromptAgent{ Tools: []any{ map[string]any{ "type": "file_search", @@ -156,7 +156,7 @@ func TestInjectFileSearchTool_NoDuplicateID(t *testing.T) { } func TestFileStoreNode_NoFilesNoNode(t *testing.T) { - g := &promptGraph{managed: &agent_yaml.ManagedAgent{}, bindings: map[string]any{}} + g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} node := fileStoreNode(g, nil, func() (vectorStoreBuilder, error) { return nil, nil }) if node != nil { t.Fatal("expected no node when there are no files") @@ -164,7 +164,7 @@ func TestFileStoreNode_NoFilesNoNode(t *testing.T) { } func TestFileStoreNode_InjectsFileSearch(t *testing.T) { - managed := &agent_yaml.ManagedAgent{Model: "m", Instructions: "i"} + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} managed.Name = "agent" g := &promptGraph{managed: managed, bindings: map[string]any{}} fake := &fakeVectorStoreBuilder{storeID: "vs-42"} @@ -198,7 +198,7 @@ func TestFileStoreNode_InjectsFileSearch(t *testing.T) { } func TestFileStoreNode_ValidateRejectsEmptyFile(t *testing.T) { - g := &promptGraph{managed: &agent_yaml.ManagedAgent{}, bindings: map[string]any{}} + g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} files := []fileEntry{{Name: "empty.md", Hash: "h", Content: []byte{}}} node := fileStoreNode(g, files, func() (vectorStoreBuilder, error) { return nil, nil }) if node == nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go index d5e08ddee3c..d12f83585ff 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go @@ -53,7 +53,7 @@ type promptGraph struct { // managed is the parsed agent definition. Nodes may enrich managed.Tools // with resolved bindings (e.g. a file_search or mcp tool) before publish. - managed *agent_yaml.ManagedAgent + managed *agent_yaml.PromptAgent // settings holds the resolved harness/connection target for the agent. settings *PromptAgentSettings @@ -73,7 +73,7 @@ type promptGraph struct { // registered today; file/skill/connection nodes are added by later stages. func newPromptGraph( agentDir string, - managed *agent_yaml.ManagedAgent, + managed *agent_yaml.PromptAgent, settings *PromptAgentSettings, env map[string]string, ) (*promptGraph, error) { @@ -164,6 +164,17 @@ func (g *promptGraph) agentNode() promptNode { // order. Validation runs to completion before any Resolve so a failure never // leaves a half-wired agent. func (g *promptGraph) resolve(ctx context.Context, progress azdext.ProgressReporter) error { + // Surface which convention nodes were discovered via the progress reporter + // (the extension's stderr is not forwarded to the azd console, so this is + // the only reliable way to report it during a deploy). + if progress != nil { + kinds := make([]string, 0, len(g.nodes)) + for _, n := range g.nodes { + kinds = append(kinds, string(n.Kind)) + } + progress(fmt.Sprintf("Prompt graph nodes: %s", strings.Join(kinds, ", "))) + } + for _, n := range g.nodes { if n.Validate == nil { continue @@ -193,7 +204,7 @@ func (g *promptGraph) resolve(ctx context.Context, progress azdext.ProgressRepor // so any resolved bindings are reflected in the published agent definition. func (p *AgentServiceTargetProvider) resolvePromptAgentGraph( ctx context.Context, - managed *agent_yaml.ManagedAgent, + managed *agent_yaml.PromptAgent, settings *PromptAgentSettings, env map[string]string, progress azdext.ProgressReporter, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go index c682d665103..e30715dbee2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go @@ -205,7 +205,7 @@ func extractFrontmatter(content string) (frontmatterResult, error) { // injectMcpTool ensures the agent's tools include an mcp tool for the given // toolbox label and MCP url. An existing mcp tool with the same server_url is // left in place (not duplicated). The managed definition is mutated in place. -func injectMcpTool(managed *agent_yaml.ManagedAgent, serverLabel, mcpURL string) { +func injectMcpTool(managed *agent_yaml.PromptAgent, serverLabel, mcpURL string) { if managed == nil || strings.TrimSpace(mcpURL) == "" { return } @@ -333,6 +333,17 @@ func (b *foundryToolboxBuilder) EnsureToolbox( return "", fmt.Errorf("registering skill %q: %w", s.Meta.Name, err) } + // Creating a version does NOT make it the skill's default_version — + // the Foundry API only auto-promotes the very first version. Without + // this, redeploying with changed skill content registers a new + // version that the Foundry portal's skill view (and any unversioned + // reference) never surfaces, making the update look like it didn't + // happen. Promote every newly created version to default so the + // latest deploy is always what's active. + if err := b.skills.PromoteSkillVersion(ctx, version.Name, version.Version); err != nil { + return "", fmt.Errorf("promoting skill %q to version %s: %w", s.Meta.Name, version.Version, err) + } + ref := map[string]any{ "type": "skill_reference", "name": version.Name, @@ -352,6 +363,14 @@ func (b *foundryToolboxBuilder) EnsureToolbox( if err != nil { return "", fmt.Errorf("creating toolbox version: %w", err) } + + // Same reasoning as the skill promotion above: creating a toolbox version + // doesn't promote it, so the toolbox consumer endpoint (and the portal) + // would keep serving the previous version's tool/skill set otherwise. + if err := b.toolboxes.PromoteToolboxVersion(ctx, toolboxName, created.Version); err != nil { + return "", fmt.Errorf("promoting toolbox %q to version %s: %w", toolboxName, created.Version, err) + } + return b.mcpURL(created.Name, created.Version), nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go index ed4d7c276fa..02e53b2ebf6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go @@ -157,7 +157,7 @@ func TestScanSkillsDir_Empty(t *testing.T) { } func TestInjectMcpTool_AddsWhenAbsent(t *testing.T) { - managed := &agent_yaml.ManagedAgent{} + managed := &agent_yaml.PromptAgent{} injectMcpTool(managed, "toolbox-a", "https://proj/mcp") if len(managed.Tools) != 1 { @@ -170,7 +170,7 @@ func TestInjectMcpTool_AddsWhenAbsent(t *testing.T) { } func TestInjectMcpTool_NotDuplicated(t *testing.T) { - managed := &agent_yaml.ManagedAgent{ + managed := &agent_yaml.PromptAgent{ Tools: []any{ map[string]any{"type": "mcp", "server_url": "https://proj/mcp"}, }, @@ -182,7 +182,7 @@ func TestInjectMcpTool_NotDuplicated(t *testing.T) { } func TestToolboxNode_PrimaryRegistersSkills(t *testing.T) { - managed := &agent_yaml.ManagedAgent{Model: "m", Instructions: "i"} + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} managed.Name = "agent" g := &promptGraph{managed: managed, bindings: map[string]any{}} fake := &fakeToolboxBuilder{} @@ -213,7 +213,7 @@ func TestToolboxNode_PrimaryRegistersSkills(t *testing.T) { } func TestToolboxNode_FallbackReferenceExisting(t *testing.T) { - managed := &agent_yaml.ManagedAgent{Model: "m", Instructions: "i"} + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} managed.Name = "agent" g := &promptGraph{managed: managed, bindings: map[string]any{}} fake := &fakeToolboxBuilder{} @@ -242,7 +242,7 @@ func TestToolboxNode_FallbackReferenceExisting(t *testing.T) { } func TestToolboxNode_NoneReturnsNil(t *testing.T) { - g := &promptGraph{managed: &agent_yaml.ManagedAgent{}, bindings: map[string]any{}} + g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} node := toolboxNode(g, nil, nil, func() (toolboxBuilder, error) { return nil, nil }) if node != nil { t.Fatal("expected nil node when no skills and no reference") diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_tools_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_tools_test.go index cb10349d777..1d87fae2519 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_tools_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_tools_test.go @@ -24,7 +24,7 @@ import ( // braydonk/yaml, which must produce JSON-marshalable maps/slices. func TestPromptAgentToolsPassthrough_BraydonkDecoder(t *testing.T) { yamlContent := []byte(` -kind: managed +kind: prompt name: kitchen-sink-agent model: gpt-4o instructions: You are a maximally capable assistant. @@ -56,17 +56,17 @@ tools: `) // Decode with the SAME library the deploy path uses. - var managed agent_yaml.ManagedAgent - if err := yaml.Unmarshal(yamlContent, &managed); err != nil { + var promptDef agent_yaml.PromptAgent + if err := yaml.Unmarshal(yamlContent, &promptDef); err != nil { t.Fatalf("braydonk unmarshal: %v", err) } - if len(managed.Tools) != 4 { - t.Fatalf("tools: got %d, want 4", len(managed.Tools)) + if len(promptDef.Tools) != 4 { + t.Fatalf("tools: got %d, want 4", len(promptDef.Tools)) } - req, err := agent_yaml.CreateManagedAgentAPIRequest(managed, nil) + req, err := agent_yaml.CreatePromptAgentAPIRequest(promptDef, nil) if err != nil { - t.Fatalf("CreateManagedAgentAPIRequest: %v", err) + t.Fatalf("CreatePromptAgentAPIRequest: %v", err) } data, err := json.Marshal(req) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go index a05da716058..e4a9413745c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "net/http" "net/url" "os" "path/filepath" @@ -72,49 +73,49 @@ func (p *AgentServiceTargetProvider) promptAgentSettings() (*PromptAgentSettings return cfg.PromptAgent, nil } -// loadPromptAgentDefinition reads the agent.yaml as a bare ManagedAgent. +// loadPromptAgentDefinition reads the agent.yaml as a bare PromptAgent. // // Convention: when the YAML omits inline `instructions:`, a sibling // `instructions.md` (next to agent.yaml) is used as the agent's instructions. // Inline `instructions:` always takes precedence over the file. -func (p *AgentServiceTargetProvider) loadPromptAgentDefinition() (agent_yaml.ManagedAgent, error) { +func (p *AgentServiceTargetProvider) loadPromptAgentDefinition() (agent_yaml.PromptAgent, error) { data, err := os.ReadFile(p.agentDefinitionPath) if err != nil { - return agent_yaml.ManagedAgent{}, exterrors.Validation( + return agent_yaml.PromptAgent{}, exterrors.Validation( exterrors.CodeInvalidAgentManifest, fmt.Sprintf("failed to read agent manifest file: %s", err), "verify the agent.yaml file exists and is readable", ) } if err := validatePromptAgentRawFields(data); err != nil { - return agent_yaml.ManagedAgent{}, err + return agent_yaml.PromptAgent{}, err } - var managed agent_yaml.ManagedAgent - if err := yaml.Unmarshal(data, &managed); err != nil { - return agent_yaml.ManagedAgent{}, exterrors.Validation( + var promptDef agent_yaml.PromptAgent + if err := yaml.Unmarshal(data, &promptDef); err != nil { + return agent_yaml.PromptAgent{}, exterrors.Validation( exterrors.CodeInvalidAgentManifest, fmt.Sprintf("agent.yaml is not a valid prompt agent: %s", err), - "fix the agent.yaml to match the prompt (managed) agent schema", + "fix the agent.yaml to match the prompt agent schema", ) } - if !strings.EqualFold(string(managed.Kind), string(agent_yaml.AgentKindManaged)) { - return agent_yaml.ManagedAgent{}, exterrors.Validation( + if !strings.EqualFold(string(promptDef.Kind), string(agent_yaml.AgentKindPrompt)) { + return agent_yaml.PromptAgent{}, exterrors.Validation( exterrors.CodeUnsupportedAgentKind, - fmt.Sprintf("agent.yaml declares kind %q, expected managed", managed.Kind), - "use kind: managed for prompt agents", + fmt.Sprintf("agent.yaml declares kind %q, expected prompt", promptDef.Kind), + "use kind: prompt for prompt agents", ) } // Convention: fall back to a sibling instructions.md when instructions are // not declared inline. Inline instructions win. - if strings.TrimSpace(managed.Instructions) == "" { + if strings.TrimSpace(promptDef.Instructions) == "" { instructionsPath := filepath.Join(filepath.Dir(p.agentDefinitionPath), promptInstructionsFileName) if content, readErr := os.ReadFile(instructionsPath); readErr == nil { - managed.Instructions = string(content) + promptDef.Instructions = string(content) } } - return managed, nil + return promptDef, nil } // promptInstructionsFileName is the conventional sidecar file whose contents @@ -138,7 +139,7 @@ var containerOnlyPromptFields = []string{ // validatePromptAgentRawFields rejects container-only fields on a prompt agent. // // The YAML decoder silently drops unknown fields, so a probe decode into a -// generic map is used to detect container-only keys that the typed ManagedAgent +// generic map is used to detect container-only keys that the typed PromptAgent // would otherwise ignore, surfacing a clear error instead of silently ignoring // misplaced configuration. func validatePromptAgentRawFields(data []byte) error { @@ -152,7 +153,7 @@ func validatePromptAgentRawFields(data []byte) error { if _, ok := probe[field]; ok { return exterrors.Validation( exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("field %q is not valid for a prompt (kind: managed) agent", field), + fmt.Sprintf("field %q is not valid for a prompt (kind: prompt) agent", field), "remove container-only fields (image, protocols, code_configuration, ...) "+ "or use kind: hosted for container agents", ) @@ -248,7 +249,7 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( return nil, err } - request, err := agent_yaml.CreateManagedAgentAPIRequest(managed, nil) + request, err := agent_yaml.CreatePromptAgentAPIRequest(managed, nil) if err != nil { return nil, exterrors.Validation( exterrors.CodeInvalidAgentManifest, @@ -268,14 +269,14 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( headers := map[string]string{ "x-model-endpoint": settings.EffectiveModelEndpoint(), } - agent, err := client.CreateAgentWithHeaders(ctx, request, settings.EffectiveAPIVersion(), headers) + agent, err := p.createOrUpdatePromptAgent(ctx, client, request, settings, headers) if err != nil && isWorkspaceNotFoundError(err) && !projectScopedTarget { // Workspace provisioning may not have finished or may have raced; retry once. if env2, envErr2 := p.azdEnvValues(ctx); envErr2 == nil { if createErr := ensurePromptWorkspaceExists(ctx, settings, env2, progress); createErr == nil { fmt.Fprintf(os.Stderr, "Retrying agent creation after workspace provisioning.\n") if client2, clientErr := NewPromptAgentClient(settings); clientErr == nil { - agent, err = client2.CreateAgentWithHeaders(ctx, request, settings.EffectiveAPIVersion(), headers) + agent, err = p.createOrUpdatePromptAgent(ctx, client2, request, settings, headers) } } } @@ -649,3 +650,56 @@ func isWorkspaceNotFoundError(err error) bool { return strings.Contains(msg, "workspacenotfound") || strings.Contains(msg, "workspace not found") } + +// isAgentConflictError reports whether err is a 409 Conflict from the managed +// agent create endpoint, which the harness returns when an agent with the same +// name already exists. +func isAgentConflictError(err error) bool { + if err == nil { + return false + } + if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + if respErr.StatusCode == http.StatusConflict { + return true + } + if strings.EqualFold(strings.TrimSpace(respErr.ErrorCode), "conflict") { + return true + } + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "already exists") +} + +// createOrUpdatePromptAgent publishes the agent definition, creating the agent +// on first deploy and publishing a new version on subsequent deploys. +// +// Managed (prompt) agents are versioned: the create endpoint (POST /agents) +// only succeeds for a brand-new agent and returns 409 Conflict once the agent +// exists. Re-deploys therefore fall back to the update endpoint +// (POST /agents/{name}), which appends a new version. This makes `azd deploy` +// idempotent: the first run creates the agent, and every later run bumps its +// version. +func (p *AgentServiceTargetProvider) createOrUpdatePromptAgent( + ctx context.Context, + client *agent_api.ManagedAgentClient, + request *agent_api.CreateAgentRequest, + settings *PromptAgentSettings, + headers map[string]string, +) (*agent_api.AgentObject, error) { + apiVersion := settings.EffectiveAPIVersion() + + agent, err := client.CreateAgentWithHeaders(ctx, request, apiVersion, headers) + if err == nil { + return agent, nil + } + if !isAgentConflictError(err) { + return nil, err + } + + // The agent already exists — publish a new version instead. + fmt.Fprintf(os.Stderr, "Agent %q already exists; publishing a new version.\n", request.Name) + updateReq := &agent_api.UpdateAgentRequest{ + CreateAgentVersionRequest: request.CreateAgentVersionRequest, + } + return client.UpdateAgentWithHeaders(ctx, request.Name, updateReq, apiVersion, headers) +} From 1ee803fe6b6273fe9651bfabf8693d64f9e3b8c6 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Wed, 15 Jul 2026 19:17:36 +0530 Subject: [PATCH 07/24] Managed Harness Agents PrPr --- cli/azd/extensions/azure.ai.agents/extension.yaml | 2 +- cli/azd/extensions/azure.ai.agents/version.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index 76ac370f15e..513bbbd14b6 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -5,7 +5,7 @@ displayName: Foundry agents (Preview) description: Ship agents with Microsoft Foundry from your terminal. (Preview) usage: azd ai agent [options] # NOTE: Make sure version.txt is in sync with this version. -version: 0.1.43-preview +version: 0.1.44-preview requiredAzdVersion: ">1.25.2" dependencies: - id: azure.ai.inspector diff --git a/cli/azd/extensions/azure.ai.agents/version.txt b/cli/azd/extensions/azure.ai.agents/version.txt index 1b74d47f683..42f862823b2 100644 --- a/cli/azd/extensions/azure.ai.agents/version.txt +++ b/cli/azd/extensions/azure.ai.agents/version.txt @@ -1 +1 @@ -0.1.43-preview +0.1.44-preview From c5398d6b7902a113a5787dd6d9511934316bac8b Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Wed, 15 Jul 2026 19:17:56 +0530 Subject: [PATCH 08/24] Managed Harness Agents PrPr --- cli/azd/extensions/registry.json | 82 ++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/cli/azd/extensions/registry.json b/cli/azd/extensions/registry.json index 5c06f52d011..001d6971fa0 100644 --- a/cli/azd/extensions/registry.json +++ b/cli/azd/extensions/registry.json @@ -5173,6 +5173,88 @@ "version": "~0.0.1-preview" } ] + }, + { + "version": "0.1.44-preview", + "requiredAzdVersion": "\u003e1.25.2", + "capabilities": [ + "custom-commands", + "lifecycle-events", + "mcp-server", + "service-target-provider", + "metadata" + ], + "providers": [ + { + "name": "azure.ai.agent", + "type": "service-target", + "description": "Deploys agents to the Foundry Agent Service" + } + ], + "usage": "azd ai agent \u003ccommand\u003e [options]", + "examples": [ + { + "name": "init", + "description": "Initialize a new AI agent project.", + "usage": "azd ai agent init" + } + ], + "artifacts": { + "darwin/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "8cae1b35438b2a79fed0b0b29fd423bffa0ced33b399dddb64042ea0b76b1ff2" + }, + "entryPoint": "azure-ai-agents-darwin-amd64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.44-preview/azure-ai-agents-darwin-amd64.zip" + }, + "darwin/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "63820d4ef6bfacf42462d5d41f9f9bcd9a0ba84b3a0c8dafeb9178fd07a54ff3" + }, + "entryPoint": "azure-ai-agents-darwin-arm64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.44-preview/azure-ai-agents-darwin-arm64.zip" + }, + "linux/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "80b98ee277f9b854887d10f1b866dfee5a0c5cf68d379bb110d589f2966b1a4e" + }, + "entryPoint": "azure-ai-agents-linux-amd64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.44-preview/azure-ai-agents-linux-amd64.tar.gz" + }, + "linux/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "b0a4653bf159a5ea7d810cc1d797f918ae02dd5fef5b8556915b26366edca0cb" + }, + "entryPoint": "azure-ai-agents-linux-arm64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.44-preview/azure-ai-agents-linux-arm64.tar.gz" + }, + "windows/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "e6d9d60aacb8f93b6ab46be714485cdd3e5b27c932b71e5c26e638ccb8398481" + }, + "entryPoint": "azure-ai-agents-windows-amd64.exe", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.44-preview/azure-ai-agents-windows-amd64.zip" + }, + "windows/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "68b39fcf9a500b7c09e1425b03cd4f4ec6dbce6c653ef336712a9b052d867193" + }, + "entryPoint": "azure-ai-agents-windows-arm64.exe", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.44-preview/azure-ai-agents-windows-arm64.zip" + } + }, + "dependencies": [ + { + "id": "azure.ai.inspector", + "version": "~0.0.1-preview" + } + ] } ] }, From cfea4e83af069337a46019f261e5534efaa043a6 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Fri, 17 Jul 2026 04:51:42 -0700 Subject: [PATCH 09/24] MHA bug fixes: 5446971, 5444617 --- .../extensions/azure.ai.agents/CHANGELOG.md | 1 + .../internal/cmd/invoke_managed.go | 69 ++++++++++++++-- .../internal/cmd/invoke_managed_test.go | 35 +++++++- .../azure.ai.agents/internal/cmd/listen.go | 15 +++- .../internal/cmd/prompt_service.go | 16 ++++ .../pkg/azure/foundry_skills_client.go | 82 +++++++++++++++++++ .../pkg/azure/foundry_skills_client_test.go | 64 +++++++++++++++ .../internal/project/prompt_skills.go | 60 ++++++++++---- .../project/prompt_skills_bundle_test.go | 69 ++++++++++++++++ 9 files changed, 384 insertions(+), 27 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_bundle_test.go diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index feeda7d5193..981ea4d7759 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -11,6 +11,7 @@ - The manifest parser recognizes `skill` and `file` resource kinds. - `azd ai agent init` now scaffolds the prompt-agent authoring layout: an `instructions.md` sidecar (instructions are written there instead of inline in `agent.yaml`) plus empty `files/` and `skills/` folders so the deploy conventions are discoverable from a fresh init. - **Breaking:** the `agent.yaml` discriminator for prompt agents is now `kind: prompt` (was `kind: managed`). Existing `agent.yaml` files must be updated; the scaffolded schema annotation now points at `PromptAgent.yaml`. The `--kind managed` init flag value is still accepted as a backward-compatible alias for `--kind prompt`. +- Fixed a bug where only `SKILL.md` was uploaded when registering a skill under `skills//` — any other files in the bundle (e.g. `references/`, `assets/`, `scripts/`, at any nesting depth) were silently dropped. Skill registration now uploads the entire bundle via multipart upload instead of sending just the parsed `SKILL.md` body inline. ## 0.1.41-preview (2026-06-19) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go index 9678cfa5257..8484359b3d8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go @@ -9,10 +9,13 @@ import ( "encoding/json" "fmt" "io" + "log" "os" "strings" "azureaiagent/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) // managedAgentReference is the body fragment that binds a Responses call to a @@ -31,6 +34,10 @@ type managedResponsesRequest struct { Stream bool `json:"stream"` AgentReference managedAgentReference `json:"agent_reference"` Tools []any `json:"tools"` + // PreviousResponseID chains this turn to the previous one so the harness + // restores prior conversation context (multi-turn memory). Empty on the + // first turn of a conversation; omitted from the payload when empty. + PreviousResponseID string `json:"previous_response_id,omitempty"` } // runPromptInvoke sends a message to a prompt (kind=managed) agent via the @@ -57,12 +64,35 @@ func (a *InvokeAction) runPromptInvoke(ctx context.Context, pctx *promptServiceC return err } + // Resolve multi-turn state. Prompt agents chain turns via the OpenAI + // Responses `previous_response_id`: azd persists the last response id per + // agent and sends it on the next invoke so the harness restores prior + // conversation context. Best-effort — a config-store failure degrades to a + // stateless (single-turn) invoke rather than blocking the call. + agentKey := pctx.agentKey(agentName) + azdClient, err := azdext.NewAzdClient() + if err != nil { + log.Printf("invoke prompt: config store unavailable, multi-turn memory disabled: %v", err) + azdClient = nil + } + if azdClient != nil { + defer azdClient.Close() + } + + var previousResponseID string + if azdClient != nil && !a.flags.newConversation { + if val, gerr := getContextValueWithFallback(ctx, azdClient, "conversations", agentKey, nil); gerr == nil { + previousResponseID = val + } + } + payload, err := json.Marshal(managedResponsesRequest{ - Model: pctx.Agent.Model, - Input: string(body), - Stream: true, - AgentReference: managedAgentReference{Type: "agent_reference", Name: agentName}, - Tools: []any{}, + Model: pctx.Agent.Model, + Input: string(body), + Stream: true, + AgentReference: managedAgentReference{Type: "agent_reference", Name: agentName}, + Tools: []any{}, + PreviousResponseID: previousResponseID, }) if err != nil { return fmt.Errorf("building prompt invoke request: %w", err) @@ -85,9 +115,15 @@ func (a *InvokeAction) runPromptInvoke(ctx context.Context, pctx *promptServiceC } defer stream.Close() - if err := streamManagedSSE(stream, os.Stdout); err != nil { + responseID, err := streamManagedSSE(stream, os.Stdout) + if err != nil { return fmt.Errorf("reading prompt agent response stream: %w", err) } + + // Persist the new response id so the next invoke continues this thread. + if azdClient != nil && responseID != "" { + saveContextValue(ctx, azdClient, agentKey, responseID, "conversations") + } return nil } @@ -98,13 +134,18 @@ func (a *InvokeAction) runPromptInvoke(ctx context.Context, pctx *promptServiceC // events (`response.created`, `response.completed`, etc.) are consumed // silently. A trailing newline is emitted after the stream ends so the shell // prompt returns on its own line. -func streamManagedSSE(r io.Reader, w io.Writer) error { +// +// The returned string is the response id parsed from the stream's lifecycle +// events (when present), which the caller persists so the next invoke can +// chain via `previous_response_id` for multi-turn memory. +func streamManagedSSE(r io.Reader, w io.Writer) (string, error) { scanner := bufio.NewScanner(r) // SSE data lines can be large (full JSON payloads); raise the buffer cap // well above the 64 KiB default so a single event never overflows it. scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) var event string + var responseID string wroteText := false for scanner.Scan() { line := scanner.Text() @@ -121,6 +162,18 @@ func streamManagedSSE(r io.Reader, w io.Writer) error { fmt.Fprint(w, payload.Delta) wroteText = true } + } else if strings.HasPrefix(event, "response.") { + // Capture the response id from any lifecycle event that carries + // it (e.g. response.created, response.completed). The last one + // seen wins so the persisted id reflects the completed turn. + var payload struct { + Response struct { + ID string `json:"id"` + } `json:"response"` + } + if err := json.Unmarshal([]byte(data), &payload); err == nil && payload.Response.ID != "" { + responseID = payload.Response.ID + } } case line == "": // Blank line terminates an SSE event block. @@ -130,5 +183,5 @@ func streamManagedSSE(r io.Reader, w io.Writer) error { if wroteText { fmt.Fprintln(w) } - return scanner.Err() + return responseID, scanner.Err() } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_test.go index f50b9d4e1ca..d548baa21ce 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_test.go @@ -28,7 +28,7 @@ func TestStreamManagedSSE_TextDeltas(t *testing.T) { }, "\n") var out strings.Builder - if err := streamManagedSSE(strings.NewReader(sse), &out); err != nil { + if _, err := streamManagedSSE(strings.NewReader(sse), &out); err != nil { t.Fatalf("streamManagedSSE: %v", err) } got := out.String() @@ -51,7 +51,7 @@ func TestStreamManagedSSE_NoText(t *testing.T) { }, "\n") var out strings.Builder - if err := streamManagedSSE(strings.NewReader(sse), &out); err != nil { + if _, err := streamManagedSSE(strings.NewReader(sse), &out); err != nil { t.Fatalf("streamManagedSSE: %v", err) } if out.String() != "" { @@ -72,10 +72,39 @@ func TestStreamManagedSSE_IgnoresMalformedData(t *testing.T) { }, "\n") var out strings.Builder - if err := streamManagedSSE(strings.NewReader(sse), &out); err != nil { + if _, err := streamManagedSSE(strings.NewReader(sse), &out); err != nil { t.Fatalf("streamManagedSSE: %v", err) } if out.String() != "ok\n" { t.Errorf("got %q, want %q", out.String(), "ok\n") } } + +// TestStreamManagedSSE_CapturesResponseID asserts the response id is parsed +// from lifecycle events so the caller can chain the next turn via +// previous_response_id. The last id seen (from response.completed) wins. +func TestStreamManagedSSE_CapturesResponseID(t *testing.T) { + sse := strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_created"}}`, + "", + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":"hi"}`, + "", + "event: response.completed", + `data: {"type":"response.completed","response":{"id":"resp_done"}}`, + "", + }, "\n") + + var out strings.Builder + id, err := streamManagedSSE(strings.NewReader(sse), &out) + if err != nil { + t.Fatalf("streamManagedSSE: %v", err) + } + if id != "resp_done" { + t.Errorf("got response id %q, want %q", id, "resp_done") + } + if out.String() != "hi\n" { + t.Errorf("got %q, want %q", out.String(), "hi\n") + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 28a886ced77..d24b90c1294 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -542,7 +542,20 @@ func kindEnvUpdate(ctx context.Context, azdClient *azdext.AzdClient, project *az } func deploymentEnvUpdate(ctx context.Context, deployments []project.Deployment, azdClient *azdext.AzdClient, envName string) error { - deploymentsJson, err := json.Marshal(deployments) + // Only expose deployments azd owns. Reused deployments (Existing == true) + // already live in the Foundry project; including them here would make the + // infra template attempt to update them and would make the quota preflight + // treat their capacity as newly requested, producing false "not enough + // quota" failures on `azd up`. + toProvision := make([]project.Deployment, 0, len(deployments)) + for _, d := range deployments { + if d.Existing { + continue + } + toProvision = append(toProvision, d) + } + + deploymentsJson, err := json.Marshal(toProvision) if err != nil { return fmt.Errorf("failed to marshal deployment details to JSON: %w", err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go index 114b07aeab2..72dfc0792a6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go @@ -5,8 +5,10 @@ package cmd import ( "context" + "fmt" "os" "path/filepath" + "strings" "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" @@ -115,6 +117,20 @@ func (p *promptServiceContext) AgentName() string { return p.ServiceName } +// agentKey returns the config-store key used to persist per-agent multi-turn +// state (the last response id) for this prompt service. It mirrors the hosted +// key scheme (buildAgentKey) so lookups and cleanup share one code path. +func (p *promptServiceContext) agentKey(agentName string) string { + endpoint := strings.TrimSpace(p.Settings.ProjectEndpoint) + if endpoint == "" { + endpoint = fmt.Sprintf( + "%s/%s/%s", + p.Settings.SubscriptionID, p.Settings.ResourceGroup, p.Settings.Workspace, + ) + } + return buildAgentKey(endpoint, agentName, "", false) +} + // newClient builds a harness client for the resolved prompt service. func (p *promptServiceContext) newClient() (*agent_api.ManagedAgentClient, error) { return project.NewPromptAgentClient(p.Settings) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go index 8a037f2e51e..bdf7936fc52 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go @@ -9,8 +9,10 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "net/url" + "sort" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -139,6 +141,86 @@ func (c *FoundrySkillsClient) CreateSkillVersion( return &result, nil } +// CreateSkillVersionFromFiles registers a skill version by uploading every +// file in a skill bundle — SKILL.md plus any references/, assets/, or other +// supporting files — via multipart/form-data. Unlike CreateSkillVersion (the +// JSON inline_content path, which only ever carries the SKILL.md body), this +// uploads the bundle's exact files: the service parses SKILL.md itself and +// stores every other file so the skill can reference them at runtime. Use +// this whenever a skill bundle contains more than a bare SKILL.md. +// +// files maps a bundle-relative path (forward-slash separated, e.g. +// "references/tone.md") to its raw content. +// +// POST {endpoint}/skills/{name}/versions?api-version=v1 (multipart/form-data) +func (c *FoundrySkillsClient) CreateSkillVersionFromFiles( + ctx context.Context, + skillName string, + files map[string][]byte, +) (*SkillVersionObject, error) { + if len(files) == 0 { + return nil, fmt.Errorf("no files to upload for skill %q", skillName) + } + + payload := &bytes.Buffer{} + writer := multipart.NewWriter(payload) + + // Sort for a deterministic request body (easier to test/debug/replay). + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + part, err := writer.CreateFormFile("files", name) + if err != nil { + return nil, fmt.Errorf("creating form file %q: %w", name, err) + } + if _, err := part.Write(files[name]); err != nil { + return nil, fmt.Errorf("writing file %q: %w", name, err) + } + } + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("closing multipart writer: %w", err) + } + + targetURL := fmt.Sprintf( + "%s/skills/%s/versions?api-version=%s", + c.endpoint, url.PathEscape(skillName), skillsApiVersion, + ) + req, err := runtime.NewRequest(ctx, http.MethodPost, targetURL) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", skillsFeatureHeader) + if err := req.SetBody( + streaming.NopCloser(bytes.NewReader(payload.Bytes())), + writer.FormDataContentType(), + ); err != nil { + return nil, fmt.Errorf("setting request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + var result SkillVersionObject + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + return &result, nil +} + // PromoteSkillVersion updates the skill's default_version, making it the // version resolved by references that omit an explicit version (including the // Foundry portal's skill view). Creating a skill version does NOT diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go index ca400690787..c1256ab56a6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go @@ -69,6 +69,70 @@ func TestCreateSkillVersion_ErrorStatus(t *testing.T) { require.Error(t, err) } +func TestCreateSkillVersionFromFiles_UploadsEveryFile(t *testing.T) { + var captured *http.Request + var body []byte + + client := newTestSkillsClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { + captured = req + if req.Body != nil { + body, _ = io.ReadAll(req.Body) + } + return &http.Response{ + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(`{"id":"s-1","name":"my-skill","version":"1.0.0"}`)), + Header: make(http.Header), + }, nil + }) + + files := map[string][]byte{ + "SKILL.md": []byte("---\nname: my-skill\n---\nbody"), + "references/tone.md": []byte("tone guidance"), + "assets/logo.svg": []byte(""), + "scripts/analysis.py": []byte("print('hi')"), + } + + out, err := client.CreateSkillVersionFromFiles(t.Context(), "my-skill", files) + require.NoError(t, err) + require.Equal(t, "1.0.0", out.Version) + + require.NotNil(t, captured) + require.Equal(t, http.MethodPost, captured.Method) + require.Equal(t, "/skills/my-skill/versions", captured.URL.EscapedPath()) + require.Contains(t, captured.Header.Get("Content-Type"), "multipart/form-data") + require.Equal(t, skillsFeatureHeader, captured.Header.Get("Foundry-Features")) + + // Every file in the bundle — not just SKILL.md — must be present in the + // multipart body. This is the regression this test guards: uploading only + // SKILL.md silently drops references/, assets/, and any other bundle files. + bodyStr := string(body) + for name, content := range files { + require.Contains(t, bodyStr, name, "multipart body missing file part for %q", name) + require.Contains(t, bodyStr, string(content), "multipart body missing content for %q", name) + } +} + +func TestCreateSkillVersionFromFiles_EmptyFilesErrors(t *testing.T) { + client := newTestSkillsClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { + t.Fatal("no HTTP request should be made when files is empty") + return nil, nil + }) + _, err := client.CreateSkillVersionFromFiles(t.Context(), "s", map[string][]byte{}) + require.Error(t, err) +} + +func TestCreateSkillVersionFromFiles_ErrorStatus(t *testing.T) { + client := newTestSkillsClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"error":"bad"}`)), + Header: make(http.Header), + }, nil + }) + _, err := client.CreateSkillVersionFromFiles(t.Context(), "s", map[string][]byte{"SKILL.md": []byte("x")}) + require.Error(t, err) +} + func TestPromoteSkillVersion_RequestShape(t *testing.T) { var captured *http.Request var body []byte diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go index e30715dbee2..cfd6bd7db0f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go @@ -312,23 +312,17 @@ func (b *foundryToolboxBuilder) EnsureToolbox( // references (distinct from `tools`), per the Foundry Skills API. skillRefs := make([]map[string]any, 0, len(skills)) for _, s := range skills { - instructions := s.Meta.Instructions - if strings.TrimSpace(instructions) == "" { - // Fall back to the raw file when the body was empty so the service - // still receives non-empty instructions. - content, err := os.ReadFile(filepath.Join(s.Path, skillFileName)) //nolint:gosec // path from skills/ folder - if err != nil { - return "", fmt.Errorf("reading %s for skill %q: %w", skillFileName, s.Meta.Name, err) - } - instructions = string(content) + // Upload every file in the bundle (SKILL.md plus any references/, + // assets/, or other supporting files), not just SKILL.md. The service + // parses SKILL.md itself from the uploaded bundle; using the JSON + // inline_content path here would silently drop everything except + // SKILL.md's body. + files, err := readSkillBundleFiles(s.Path) + if err != nil { + return "", err } - version, err := b.skills.CreateSkillVersion(ctx, s.Meta.Name, &azure.CreateSkillVersionRequest{ - InlineContent: azure.SkillInlineContent{ - Description: s.Meta.Description, - Instructions: instructions, - }, - }) + version, err := b.skills.CreateSkillVersionFromFiles(ctx, s.Meta.Name, files) if err != nil { return "", fmt.Errorf("registering skill %q: %w", s.Meta.Name, err) } @@ -403,6 +397,42 @@ func (b *foundryToolboxBuilder) mcpURL(name, version string) string { // MCP endpoint URLs. const toolboxMcpApiVersion = "v1" +// readSkillBundleFiles reads every file under a skill bundle directory — +// SKILL.md plus any references/, assets/, or other supporting files, at any +// nesting depth — into a map of bundle-relative path (forward-slash +// separated) to raw content, so the entire bundle can be uploaded together +// via the multipart skill-version API. Without this, only SKILL.md would ever +// reach the service and any files it references (scripts, docs, assets) +// would be silently dropped. +func readSkillBundleFiles(bundleDir string) (map[string][]byte, error) { + files := map[string][]byte{} + err := filepath.WalkDir(bundleDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + content, readErr := os.ReadFile(path) //nolint:gosec // path derived from the agent's skills/ folder + if readErr != nil { + return readErr + } + rel, relErr := filepath.Rel(bundleDir, path) + if relErr != nil { + return relErr + } + files[filepath.ToSlash(rel)] = content + return nil + }) + if err != nil { + return nil, fmt.Errorf("reading skill bundle %q: %w", bundleDir, err) + } + if len(files) == 0 { + return nil, fmt.Errorf("skill bundle %q contains no files", bundleDir) + } + return files, nil +} + // newFoundryToolboxBuilder constructs the live builder from prompt settings. func newFoundryToolboxBuilder(settings *PromptAgentSettings) (toolboxBuilder, error) { if settings == nil || strings.TrimSpace(settings.ProjectEndpoint) == "" { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_bundle_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_bundle_test.go new file mode 100644 index 00000000000..75b01ba3116 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_bundle_test.go @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "os" + "path/filepath" + "sort" + "testing" +) + +// TestReadSkillBundleFiles_ReadsAllNestedFiles verifies that every file in a +// skill bundle — SKILL.md plus references/, assets/, and scripts/ subfolders — +// is picked up, not just SKILL.md. This is the core of the reported bug: only +// SKILL.md was ever read/uploaded, silently dropping everything else. +func TestReadSkillBundleFiles_ReadsAllNestedFiles(t *testing.T) { + dir := t.TempDir() + files := map[string]string{ + "SKILL.md": "---\nname: s\ndescription: d\n---\nbody", + "references/tone.md": "tone guidance", + "assets/logo.svg": "", + "scripts/analysis.py": "print('hi')", + } + for rel, content := range files { + full := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil { + t.Fatalf("mkdir for %s: %v", rel, err) + } + if err := os.WriteFile(full, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + + got, err := readSkillBundleFiles(dir) + if err != nil { + t.Fatalf("readSkillBundleFiles: %v", err) + } + + if len(got) != len(files) { + t.Fatalf("got %d files, want %d: %v", len(got), len(files), keysOf(got)) + } + for rel, want := range files { + content, ok := got[rel] + if !ok { + t.Errorf("missing bundle file %q in result", rel) + continue + } + if string(content) != want { + t.Errorf("file %q content: got %q, want %q", rel, content, want) + } + } +} + +func keysOf(m map[string][]byte) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func TestReadSkillBundleFiles_EmptyDirErrors(t *testing.T) { + dir := t.TempDir() + if _, err := readSkillBundleFiles(dir); err == nil { + t.Fatal("expected an error for an empty bundle directory") + } +} From d6f9fb5cdcbfd8a9e2c2fc1c3a8ad0d5b216e5c9 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Fri, 17 Jul 2026 05:18:57 -0700 Subject: [PATCH 10/24] removing files subfolder from project scaffold --- cli/azd/extensions/azure.ai.agents/CHANGELOG.md | 2 +- .../azure.ai.agents/internal/cmd/init_managed.go | 8 +++----- .../azure.ai.agents/internal/cmd/init_managed_test.go | 10 ++++++++-- .../azure.ai.agents/internal/project/config.go | 8 ++++++++ 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 981ea4d7759..eef66169369 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -9,7 +9,7 @@ - A `connections:` block resolves through a precedence ladder (use existing, create-if-missing with Entra default, auto-fill target from provisioning outputs, or provision/fail-fast), and each tool's required role is surfaced for assignment. - The model deployment is create-if-missing, and container-only fields (`image`, `protocols`, `code_configuration`, …) are rejected for prompt agents. - The manifest parser recognizes `skill` and `file` resource kinds. -- `azd ai agent init` now scaffolds the prompt-agent authoring layout: an `instructions.md` sidecar (instructions are written there instead of inline in `agent.yaml`) plus empty `files/` and `skills/` folders so the deploy conventions are discoverable from a fresh init. +- `azd ai agent init` now scaffolds the prompt-agent authoring layout: an `instructions.md` sidecar (instructions are written there instead of inline in `agent.yaml`) plus an empty `skills/` folder so the deploy conventions are discoverable from a fresh init. - **Breaking:** the `agent.yaml` discriminator for prompt agents is now `kind: prompt` (was `kind: managed`). Existing `agent.yaml` files must be updated; the scaffolded schema annotation now points at `PromptAgent.yaml`. The `--kind managed` init flag value is still accepted as a backward-compatible alias for `--kind prompt`. - Fixed a bug where only `SKILL.md` was uploaded when registering a skill under `skills//` — any other files in the bundle (e.g. `references/`, `assets/`, `scripts/`, at any nesting depth) were silently dropped. Skill registration now uploads the entire bundle via multipart upload instead of sending just the parsed `SKILL.md` body inline. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go index e5d48fcb34b..7e667c8cb25 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go @@ -156,8 +156,8 @@ func runInitManaged( return err } - // Scaffold the convention-based authoring layout (instructions.md + empty - // files/ and skills/ folders) so the deploy engine's folder conventions are + // Scaffold the convention-based authoring layout (instructions.md + an + // empty skills/ folder) so the deploy engine's folder conventions are // discoverable from a fresh init. if err := scaffoldPromptConventionFolders(serviceRelPath, instructions); err != nil { return err @@ -440,7 +440,6 @@ func writePromptAgentYAML(targetDir string, promptAgent *agent_yaml.PromptAgent) // // - instructions.md — the agent's instructions (deploy uses this when the // agent.yaml has no inline instructions). -// - files/ — drop documents here to get file_search automatically. // - skills/ — add one subfolder per skill (each with a SKILL.md). // // The empty folders are kept with a .gitkeep placeholder. The deploy scanners @@ -460,7 +459,7 @@ func scaffoldPromptConventionFolders(targetDir, instructions string) error { log.Printf("Wrote instructions.md at %s", instructionsPath) } - for _, sub := range []string{"files", "skills"} { + for _, sub := range []string{"skills"} { dir := filepath.Join(targetDir, sub) if err := os.MkdirAll(dir, osutil.PermissionDirectory); err != nil { return fmt.Errorf("creating %s folder: %w", sub, err) @@ -508,7 +507,6 @@ func printManagedInitSummary( fmt.Println() fmt.Println("Authoring layout (edit these to add capabilities):") fmt.Printf(" %sinstructions.md the agent's instructions\n", dirPrefix) - fmt.Printf(" %sfiles/ drop documents here for automatic file search\n", dirPrefix) fmt.Printf(" %sskills/ add a subfolder per skill (each with a SKILL.md)\n", dirPrefix) fmt.Println() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go index 0d9b745655a..64b4828fbce 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go @@ -25,8 +25,8 @@ func TestScaffoldPromptConventionFolders_CreatesLayout(t *testing.T) { t.Errorf("instructions.md content: got %q", string(content)) } - // files/ and skills/ exist with a .gitkeep placeholder. - for _, sub := range []string{"files", "skills"} { + // skills/ exists with a .gitkeep placeholder. + for _, sub := range []string{"skills"} { info, statErr := os.Stat(filepath.Join(dir, sub)) if statErr != nil || !info.IsDir() { t.Errorf("%s/ should be a directory: %v", sub, statErr) @@ -35,6 +35,12 @@ func TestScaffoldPromptConventionFolders_CreatesLayout(t *testing.T) { t.Errorf("%s/.gitkeep should exist: %v", sub, keepErr) } } + + // files/ is intentionally not scaffolded: file search is not supported for + // managed (prompt) agents. + if _, statErr := os.Stat(filepath.Join(dir, "files")); !os.IsNotExist(statErr) { + t.Errorf("files/ should not be created, got stat err: %v", statErr) + } } func TestScaffoldPromptConventionFolders_DefaultInstructions(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config.go b/cli/azd/extensions/azure.ai.agents/internal/project/config.go index 2ebc1ca37f7..ca0a02256c2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -78,6 +78,14 @@ type Deployment struct { // The resource model definition representing SKU. Sku DeploymentSku `json:"sku"` + + // Existing marks a deployment that already lives in the target Foundry + // project and was selected for reuse during init. azd does not own such + // deployments: they are excluded from the AI_PROJECT_DEPLOYMENTS list that + // the infra template provisions, so `azd up` neither updates them nor + // treats their capacity as new in the quota preflight. Omitted (false) for + // deployments azd creates. + Existing bool `json:"existing,omitempty"` } // DeploymentModel represents the model configuration for a model deployment From 1217b85e8107b01eb88f2c08ccfbe210cb5eaf9f Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Fri, 17 Jul 2026 06:01:24 -0700 Subject: [PATCH 11/24] removing files subfolder from project scaffold --- .../azure.ai.agents/internal/cmd/listen.go | 15 +-------------- .../azure.ai.agents/internal/project/config.go | 8 -------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index d24b90c1294..28a886ced77 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -542,20 +542,7 @@ func kindEnvUpdate(ctx context.Context, azdClient *azdext.AzdClient, project *az } func deploymentEnvUpdate(ctx context.Context, deployments []project.Deployment, azdClient *azdext.AzdClient, envName string) error { - // Only expose deployments azd owns. Reused deployments (Existing == true) - // already live in the Foundry project; including them here would make the - // infra template attempt to update them and would make the quota preflight - // treat their capacity as newly requested, producing false "not enough - // quota" failures on `azd up`. - toProvision := make([]project.Deployment, 0, len(deployments)) - for _, d := range deployments { - if d.Existing { - continue - } - toProvision = append(toProvision, d) - } - - deploymentsJson, err := json.Marshal(toProvision) + deploymentsJson, err := json.Marshal(deployments) if err != nil { return fmt.Errorf("failed to marshal deployment details to JSON: %w", err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config.go b/cli/azd/extensions/azure.ai.agents/internal/project/config.go index ca0a02256c2..2ebc1ca37f7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -78,14 +78,6 @@ type Deployment struct { // The resource model definition representing SKU. Sku DeploymentSku `json:"sku"` - - // Existing marks a deployment that already lives in the target Foundry - // project and was selected for reuse during init. azd does not own such - // deployments: they are excluded from the AI_PROJECT_DEPLOYMENTS list that - // the infra template provisions, so `azd up` neither updates them nor - // treats their capacity as new in the quota preflight. Omitted (false) for - // deployments azd creates. - Existing bool `json:"existing,omitempty"` } // DeploymentModel represents the model configuration for a model deployment From 9d92a95d7dcc072f65a2086d21d20e894a467347 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Fri, 17 Jul 2026 06:18:38 -0700 Subject: [PATCH 12/24] prpr 1.45-preview release --- .../extensions/azure.ai.agents/extension.yaml | 2 +- .../extensions/azure.ai.agents/version.txt | 2 +- cli/azd/extensions/registry.json | 82 +++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index 513bbbd14b6..0ba496ed695 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -5,7 +5,7 @@ displayName: Foundry agents (Preview) description: Ship agents with Microsoft Foundry from your terminal. (Preview) usage: azd ai agent [options] # NOTE: Make sure version.txt is in sync with this version. -version: 0.1.44-preview +version: 0.1.45-preview requiredAzdVersion: ">1.25.2" dependencies: - id: azure.ai.inspector diff --git a/cli/azd/extensions/azure.ai.agents/version.txt b/cli/azd/extensions/azure.ai.agents/version.txt index 42f862823b2..df402df9a1b 100644 --- a/cli/azd/extensions/azure.ai.agents/version.txt +++ b/cli/azd/extensions/azure.ai.agents/version.txt @@ -1 +1 @@ -0.1.44-preview +0.1.45-preview diff --git a/cli/azd/extensions/registry.json b/cli/azd/extensions/registry.json index 001d6971fa0..bb2b360b57b 100644 --- a/cli/azd/extensions/registry.json +++ b/cli/azd/extensions/registry.json @@ -5255,6 +5255,88 @@ "version": "~0.0.1-preview" } ] + }, + { + "version": "0.1.45-preview", + "requiredAzdVersion": "\u003e1.25.2", + "capabilities": [ + "custom-commands", + "lifecycle-events", + "mcp-server", + "service-target-provider", + "metadata" + ], + "providers": [ + { + "name": "azure.ai.agent", + "type": "service-target", + "description": "Deploys agents to the Foundry Agent Service" + } + ], + "usage": "azd ai agent \u003ccommand\u003e [options]", + "examples": [ + { + "name": "init", + "description": "Initialize a new AI agent project.", + "usage": "azd ai agent init" + } + ], + "artifacts": { + "darwin/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "38e1e008a153296a40c956c77546039e3a551e4e8c211e1f43ec72058ee6c516" + }, + "entryPoint": "azure-ai-agents-darwin-amd64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.45-preview/azure-ai-agents-darwin-amd64.zip" + }, + "darwin/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "c521116cfc39c6abbb8a078a3e9618f2ae47b9bd820467ddb0c7221e2fcc0e51" + }, + "entryPoint": "azure-ai-agents-darwin-arm64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.45-preview/azure-ai-agents-darwin-arm64.zip" + }, + "linux/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "fb5131b13ebda36aa78dd80a736988e1b6048630f2e095e1267db8941440b309" + }, + "entryPoint": "azure-ai-agents-linux-amd64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.45-preview/azure-ai-agents-linux-amd64.tar.gz" + }, + "linux/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "9c02102281e305d9bfea5b8ad749d5705d3b05702c858224afd3f2c1e0b07601" + }, + "entryPoint": "azure-ai-agents-linux-arm64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.45-preview/azure-ai-agents-linux-arm64.tar.gz" + }, + "windows/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "caec59ffc9fa356e36e69df19313e26b0ffaf96a2c085fb95978b59f8c4e327e" + }, + "entryPoint": "azure-ai-agents-windows-amd64.exe", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.45-preview/azure-ai-agents-windows-amd64.zip" + }, + "windows/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "bd8aae3b4dea8f6b51ebc8dfd1480c5ea6f948e5596c1a19d831926221832c4a" + }, + "entryPoint": "azure-ai-agents-windows-arm64.exe", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.45-preview/azure-ai-agents-windows-arm64.zip" + } + }, + "dependencies": [ + { + "id": "azure.ai.inspector", + "version": "~0.0.1-preview" + } + ] } ] }, From b97ede531f9886edffe9f31fd15773de66f07610 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Mon, 20 Jul 2026 10:09:43 -0700 Subject: [PATCH 13/24] prpr 1.46-preview release --- .../extensions/azure.ai.agents/CHANGELOG.md | 3 + .../extensions/azure.ai.agents/extension.yaml | 2 +- .../internal/cmd/init_managed_foundry.go | 8 + .../azure.ai.agents/internal/cmd/show.go | 97 +++++++++- .../azure.ai.agents/internal/cmd/show_test.go | 53 ++++++ .../azure/foundry_connections_controlplane.go | 109 +++++++++++ .../internal/project/prompt_skills.go | 169 ++++++++++++++---- .../internal/project/prompt_skills_test.go | 43 ++++- .../extensions/azure.ai.agents/version.txt | 2 +- cli/azd/extensions/registry.json | 82 +++++++++ 10 files changed, 528 insertions(+), 40 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_connections_controlplane.go diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index eef66169369..c14ce432f02 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -12,6 +12,9 @@ - `azd ai agent init` now scaffolds the prompt-agent authoring layout: an `instructions.md` sidecar (instructions are written there instead of inline in `agent.yaml`) plus an empty `skills/` folder so the deploy conventions are discoverable from a fresh init. - **Breaking:** the `agent.yaml` discriminator for prompt agents is now `kind: prompt` (was `kind: managed`). Existing `agent.yaml` files must be updated; the scaffolded schema annotation now points at `PromptAgent.yaml`. The `--kind managed` init flag value is still accepted as a backward-compatible alias for `--kind prompt`. - Fixed a bug where only `SKILL.md` was uploaded when registering a skill under `skills//` — any other files in the bundle (e.g. `references/`, `assets/`, `scripts/`, at any nesting depth) were silently dropped. Skill registration now uploads the entire bundle via multipart upload instead of sending just the parsed `SKILL.md` body inline. +- Fixed a bug where a toolbox attached to a prompt agent (via a `skills/` folder or a `toolbox:` reference) was wired into the agent's `mcp` tool without a `project_connection_id`, leaving the agent with no credential to reach the toolbox MCP endpoint so its skills were never invoked. Deploy now creates (or updates) a `RemoteTool` project connection — via the Microsoft.CognitiveServices control plane, since the data-plane connections API is read-only — that fronts the toolbox endpoint and sets it as the tool's `project_connection_id`. +- Fixed a bug where `azd up` re-prompted for an Azure region for a prompt agent even after an existing Foundry project was selected during init. Selecting an existing project now seeds `AZURE_LOCATION` from the project's region (in addition to `AZURE_AI_DEPLOYMENTS_LOCATION`), so the model is deployed to the project's region without a redundant prompt. +- `azd ai agent show` now lists the toolbox tools attached to a prompt agent — each `mcp` tool's server URL and its backing `project_connection_id` — so the toolbox created during deploy is discoverable without inspecting the deployed definition. Also fixed the `Harness` field, which previously printed the harness API base URL instead of the actual execution harness (e.g. `GitHub Copilot (ghcp)`), and added a `Project Endpoint` row showing where the agent is served. ## 0.1.41-preview (2026-06-19) diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index 0ba496ed695..13cab8589a8 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -5,7 +5,7 @@ displayName: Foundry agents (Preview) description: Ship agents with Microsoft Foundry from your terminal. (Preview) usage: azd ai agent [options] # NOTE: Make sure version.txt is in sync with this version. -version: 0.1.45-preview +version: 0.1.46-preview requiredAzdVersion: ">1.25.2" dependencies: - id: azure.ai.inspector diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go index f61233e1110..ac82880293e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go @@ -94,6 +94,14 @@ func resolvePromptHarnessTarget( if err := setEnvValue(ctx, azdClient, env.Name, "AZURE_AI_DEPLOYMENTS_LOCATION", proj.Location); err != nil { return nil, err } + // Also seed AZURE_LOCATION from the selected project's region. The + // infra main.parameters.json resolves `location` from ${AZURE_LOCATION}; + // without this, `azd up` re-prompts for a region even though the project + // (and thus the target region) is already known. Deploy the model using + // the project's region. + if err := setEnvValue(ctx, azdClient, env.Name, "AZURE_LOCATION", proj.Location); err != nil { + return nil, err + } } if err := setPromptFoundryProjectEnv(ctx, azdClient, env.Name, proj); err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go index 3fd474a36a5..429a9d398b6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go @@ -7,9 +7,11 @@ import ( "context" "encoding/json" "fmt" + "io" "maps" "os" "slices" + "strings" "text/tabwriter" "time" @@ -249,13 +251,106 @@ func printPromptShowTable(agent *agent_api.AgentObject, settings *projectpkg.Pro if latest.Status != "" { fmt.Fprintf(w, "Status:\t%s\n", latest.Status) } - fmt.Fprintf(w, "Harness:\t%s\n", settings.BaseURL) + + def := promptDefinitionMap(latest) + + // Harness is the execution harness the platform runs the agent on, taken + // from the deployed definition's `harness` field (e.g. "ghcp"). The + // previous implementation printed settings.BaseURL here, which is the + // harness *API base URL*, not the harness itself. + if harness := stringFromMap(def, "harness"); harness != "" { + fmt.Fprintf(w, "Harness:\t%s\n", displayHarness(harness)) + } + + // Project endpoint is where the agent is actually served/invoked. This is + // the useful "where does this live" value that Harness was standing in for. + if endpoint := promptAgentEndpoint(settings); endpoint != "" { + fmt.Fprintf(w, "Project Endpoint:\t%s\n", endpoint) + } + if latest.Error != nil && latest.Error.Message != "" { fmt.Fprintf(w, "Error:\t%s (%s)\n", latest.Error.Message, latest.Error.Code) } + + printPromptToolboxTools(w, def) _ = w.Flush() } +// promptDefinitionMap extracts the deployed agent version's definition as a +// generic map. The API models Definition as `any`, which decodes from JSON into +// a map[string]any; returns nil when the definition is absent or another shape. +func promptDefinitionMap(version agent_api.AgentVersionObject) map[string]any { + if def, ok := version.Definition.(map[string]any); ok { + return def + } + return nil +} + +// stringFromMap returns m[key] as a trimmed string, or "" when absent/non-string. +func stringFromMap(m map[string]any, key string) string { + if m == nil { + return "" + } + if v, ok := m[key].(string); ok { + return strings.TrimSpace(v) + } + return "" +} + +// displayHarness maps a harness identifier to a friendlier label, preserving +// the raw identifier in parentheses for unambiguous reference. +func displayHarness(harness string) string { + switch harness { + case agent_api.ManagedAgentHarnessGitHubCopilot: + return fmt.Sprintf("GitHub Copilot (%s)", harness) + default: + return harness + } +} + +// promptAgentEndpoint returns the Foundry project endpoint the prompt agent is +// served from, falling back to the harness base URL when unset. +func promptAgentEndpoint(settings *projectpkg.PromptAgentSettings) string { + if settings == nil { + return "" + } + if pe := strings.TrimSpace(settings.ProjectEndpoint); pe != "" { + return pe + } + return strings.TrimSpace(settings.BaseURL) +} + +// printPromptToolboxTools lists the mcp/toolbox tools attached to the deployed +// prompt agent, including the backing project connection that authenticates the +// agent to each toolbox. This surfaces the toolbox created during deploy without +// mutating the authored agent.yaml. +func printPromptToolboxTools(w io.Writer, def map[string]any) { + if def == nil { + return + } + rawTools, ok := def["tools"].([]any) + if !ok || len(rawTools) == 0 { + return + } + for _, raw := range rawTools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if stringFromMap(tool, "type") != "mcp" { + continue + } + label := stringFromMap(tool, "server_label") + if label == "" { + label = "mcp" + } + fmt.Fprintf(w, "Toolbox (%s):\t%s\n", label, stringFromMap(tool, "server_url")) + if conn := stringFromMap(tool, "project_connection_id"); conn != "" { + fmt.Fprintf(w, " Connection:\t%s\n", conn) + } + } +} + func printShowResult(result *showResult, output string, suggestions []nextstep.Suggestion) error { switch output { case "", "table": diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go index 9828055f4e9..874739ffd84 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go @@ -7,10 +7,12 @@ import ( "encoding/json" "io" "os" + "strings" "testing" "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/pkg/agents/agent_api" + projectpkg "azureaiagent/internal/project" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -398,3 +400,54 @@ func TestResolveNextStepFromStatus_NonActiveBranches(t *testing.T) { }) } } + +func TestDisplayHarness(t *testing.T) { + assert.Equal(t, "GitHub Copilot (ghcp)", displayHarness("ghcp")) + assert.Equal(t, "custom-harness", displayHarness("custom-harness")) +} + +func TestPromptDefinitionMap(t *testing.T) { + version := agent_api.AgentVersionObject{ + Definition: map[string]any{"harness": "ghcp"}, + } + assert.Equal(t, "ghcp", stringFromMap(promptDefinitionMap(version), "harness")) + + // Non-map definition yields nil, and stringFromMap tolerates nil. + assert.Nil(t, promptDefinitionMap(agent_api.AgentVersionObject{Definition: "not-a-map"})) + assert.Equal(t, "", stringFromMap(nil, "harness")) +} + +func TestPrintPromptToolboxTools(t *testing.T) { + def := map[string]any{ + "tools": []any{ + map[string]any{"type": "function", "name": "calc"}, // skipped + map[string]any{ + "type": "mcp", + "server_label": "agent-toolbox-01", + "server_url": "https://proj/toolboxes/agent-toolbox-01/mcp?api-version=v1", + "project_connection_id": "agent-toolbox-01-toolbox", + }, + }, + } + + var sb strings.Builder + printPromptToolboxTools(&sb, def) + out := sb.String() + + assert.Contains(t, out, "Toolbox (agent-toolbox-01):") + assert.Contains(t, out, "https://proj/toolboxes/agent-toolbox-01/mcp?api-version=v1") + assert.Contains(t, out, "Connection:") + assert.Contains(t, out, "agent-toolbox-01-toolbox") + assert.NotContains(t, out, "calc") +} + +func TestPromptAgentEndpoint(t *testing.T) { + assert.Equal(t, "https://proj/api/projects/p", promptAgentEndpoint( + &projectpkg.PromptAgentSettings{ProjectEndpoint: "https://proj/api/projects/p"}, + )) + // Falls back to BaseURL when ProjectEndpoint is unset. + assert.Equal(t, "https://ai.azure.com/api", promptAgentEndpoint( + &projectpkg.PromptAgentSettings{BaseURL: "https://ai.azure.com/api"}, + )) + assert.Equal(t, "", promptAgentEndpoint(nil)) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_connections_controlplane.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_connections_controlplane.go new file mode 100644 index 00000000000..d835ad69ca0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_connections_controlplane.go @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azure + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" +) + +// armConnectionsAPIVersion is the Microsoft.CognitiveServices control-plane +// api-version used to create project connections. The data-plane connections +// endpoint is read-only (list + getConnectionWithCredentials), so connection +// creation must go through ARM. +const armConnectionsAPIVersion = "2025-06-01" + +// FoundryConnectionsARMClient creates project connections via the Azure +// Resource Manager (control plane). It hand-rolls the request rather than using +// the typed armcognitiveservices client because the generated auth-type structs +// force their own `authType` discriminator and cannot express newer values such +// as `ProjectManagedIdentity`. +type FoundryConnectionsARMClient struct { + subscriptionID string + pipeline runtime.Pipeline +} + +// NewFoundryConnectionsARMClient builds an ARM-backed connections client. The +// pipeline authenticates against the ARM audience for the credential's cloud. +func NewFoundryConnectionsARMClient( + subscriptionID string, + cred azcore.TokenCredential, +) (*FoundryConnectionsARMClient, error) { + armClient, err := arm.NewClient("azure-ai-agents-connections", "v1.0.0", cred, NewArmClientOptions()) + if err != nil { + return nil, fmt.Errorf("creating ARM client: %w", err) + } + return &FoundryConnectionsARMClient{ + subscriptionID: subscriptionID, + pipeline: armClient.Pipeline(), + }, nil +} + +// ProjectConnectionProperties is the minimal `properties` envelope for creating +// a project connection through ARM. +type ProjectConnectionProperties struct { + Category string `json:"category"` + Target string `json:"target"` + AuthType string `json:"authType"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// UpsertProjectConnection creates (or updates) a connection under a Foundry +// project. It is idempotent: re-running with the same name updates the existing +// connection in place. +func (c *FoundryConnectionsARMClient) UpsertProjectConnection( + ctx context.Context, + resourceGroup, accountName, projectName, connectionName string, + props ProjectConnectionProperties, +) error { + target := fmt.Sprintf( + "https://management.azure.com/subscriptions/%s/resourceGroups/%s/providers/"+ + "Microsoft.CognitiveServices/accounts/%s/projects/%s/connections/%s?api-version=%s", + url.PathEscape(c.subscriptionID), + url.PathEscape(resourceGroup), + url.PathEscape(accountName), + url.PathEscape(projectName), + url.PathEscape(connectionName), + armConnectionsAPIVersion, + ) + + payload, err := json.Marshal(map[string]any{"properties": props}) + if err != nil { + return fmt.Errorf("failed to marshal connection request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPut, target) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + if err := req.SetBody( + streaming.NopCloser(bytes.NewReader(payload)), + "application/json", + ); err != nil { + return fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return runtime.NewResponseError(resp) + } + // Drain the body so the connection can be reused by the pipeline. + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go index cfd6bd7db0f..b957c42bbe7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go @@ -56,15 +56,27 @@ type toolboxRef struct { Version string } +// toolboxAttachment is the result of registering or resolving a toolbox: the +// MCP url the agent connects to plus the name of the project connection that +// authenticates the agent to that endpoint. The connection name is what the +// injected mcp tool carries as its project_connection_id — without it the agent +// has no credential to reach the toolbox and its skills are never invoked. +type toolboxAttachment struct { + McpURL string + ConnectionName string +} + // toolboxBuilder registers skills into a toolbox version (primary path) or -// resolves an existing toolbox (reference path), returning the toolbox MCP url. -// The seam keeps the graph node unit-testable without a live endpoint. +// resolves an existing toolbox (reference path), returning the toolbox MCP url +// and the project connection that fronts it. The seam keeps the graph node +// unit-testable without a live endpoint. type toolboxBuilder interface { // EnsureToolbox registers the skills into a toolbox named toolboxName and - // returns its MCP url. - EnsureToolbox(ctx context.Context, toolboxName string, skills []skillBundle) (mcpURL string, err error) - // ResolveToolbox returns the MCP url of an existing toolbox version. - ResolveToolbox(ctx context.Context, ref toolboxRef) (mcpURL string, err error) + // returns its MCP url and backing project connection. + EnsureToolbox(ctx context.Context, toolboxName string, skills []skillBundle) (toolboxAttachment, error) + // ResolveToolbox returns the MCP url and backing project connection of an + // existing toolbox version. + ResolveToolbox(ctx context.Context, ref toolboxRef) (toolboxAttachment, error) } // scanSkillsDir returns the skill bundles under /skills, one per @@ -204,8 +216,11 @@ func extractFrontmatter(content string) (frontmatterResult, error) { // injectMcpTool ensures the agent's tools include an mcp tool for the given // toolbox label and MCP url. An existing mcp tool with the same server_url is -// left in place (not duplicated). The managed definition is mutated in place. -func injectMcpTool(managed *agent_yaml.PromptAgent, serverLabel, mcpURL string) { +// left in place (not duplicated). When connectionName is non-empty it is set as +// the tool's project_connection_id so the agent can authenticate to the toolbox +// MCP endpoint; without it the toolbox skills are never invoked. The managed +// definition is mutated in place. +func injectMcpTool(managed *agent_yaml.PromptAgent, serverLabel, mcpURL, connectionName string) { if managed == nil || strings.TrimSpace(mcpURL) == "" { return } @@ -218,15 +233,26 @@ func injectMcpTool(managed *agent_yaml.PromptAgent, serverLabel, mcpURL string) continue } if fmt.Sprintf("%v", tool["server_url"]) == mcpURL { - return // already present + // Already present — backfill the connection id if it was missing so + // a previously connection-less mcp tool starts authenticating. + if strings.TrimSpace(connectionName) != "" { + if _, has := tool["project_connection_id"]; !has { + tool["project_connection_id"] = connectionName + } + } + return } } - managed.Tools = append(managed.Tools, map[string]any{ + mcpTool := map[string]any{ "type": "mcp", "server_label": serverLabel, "server_url": mcpURL, "require_approval": "always", - }) + } + if strings.TrimSpace(connectionName) != "" { + mcpTool["project_connection_id"] = connectionName + } + managed.Tools = append(managed.Tools, mcpTool) } // toolboxNode builds the skill/toolbox graph node. When ref is non-nil the @@ -274,22 +300,22 @@ func toolboxNode( } var ( - mcpURL string - label string + attachment toolboxAttachment + label string ) if ref != nil { label = ref.Name - mcpURL, err = builder.ResolveToolbox(ctx, toolboxRef{Name: ref.Name, Version: ref.Version}) + attachment, err = builder.ResolveToolbox(ctx, toolboxRef{Name: ref.Name, Version: ref.Version}) } else { label = g.managed.Name - mcpURL, err = builder.EnsureToolbox(ctx, g.managed.Name, skills) + attachment, err = builder.EnsureToolbox(ctx, g.managed.Name, skills) } if err != nil { return err } - g.bindings[toolboxMcpURLBindingKey] = mcpURL - injectMcpTool(g.managed, label, mcpURL) + g.bindings[toolboxMcpURLBindingKey] = attachment.McpURL + injectMcpTool(g.managed, label, attachment.McpURL, attachment.ConnectionName) return nil }, } @@ -300,14 +326,19 @@ func toolboxNode( type foundryToolboxBuilder struct { skills *azure.FoundrySkillsClient toolboxes *azure.FoundryToolboxClient + connections *azure.FoundryConnectionsARMClient + resourceGroup string + accountName string + projectName string projectEndpoint string } // EnsureToolbox registers each skill bundle at its pinned version, creates a -// toolbox version referencing them, and returns the toolbox MCP url. +// toolbox version referencing them, and returns the toolbox MCP url plus the +// project connection that fronts it. func (b *foundryToolboxBuilder) EnsureToolbox( ctx context.Context, toolboxName string, skills []skillBundle, -) (string, error) { +) (toolboxAttachment, error) { // Skills are attached to a toolbox via a separate `skills` array of skill // references (distinct from `tools`), per the Foundry Skills API. skillRefs := make([]map[string]any, 0, len(skills)) @@ -319,12 +350,12 @@ func (b *foundryToolboxBuilder) EnsureToolbox( // SKILL.md's body. files, err := readSkillBundleFiles(s.Path) if err != nil { - return "", err + return toolboxAttachment{}, err } version, err := b.skills.CreateSkillVersionFromFiles(ctx, s.Meta.Name, files) if err != nil { - return "", fmt.Errorf("registering skill %q: %w", s.Meta.Name, err) + return toolboxAttachment{}, fmt.Errorf("registering skill %q: %w", s.Meta.Name, err) } // Creating a version does NOT make it the skill's default_version — @@ -335,7 +366,7 @@ func (b *foundryToolboxBuilder) EnsureToolbox( // happen. Promote every newly created version to default so the // latest deploy is always what's active. if err := b.skills.PromoteSkillVersion(ctx, version.Name, version.Version); err != nil { - return "", fmt.Errorf("promoting skill %q to version %s: %w", s.Meta.Name, version.Version, err) + return toolboxAttachment{}, fmt.Errorf("promoting skill %q to version %s: %w", s.Meta.Name, version.Version, err) } ref := map[string]any{ @@ -355,27 +386,83 @@ func (b *foundryToolboxBuilder) EnsureToolbox( Skills: skillRefs, }) if err != nil { - return "", fmt.Errorf("creating toolbox version: %w", err) + return toolboxAttachment{}, fmt.Errorf("creating toolbox version: %w", err) } // Same reasoning as the skill promotion above: creating a toolbox version // doesn't promote it, so the toolbox consumer endpoint (and the portal) // would keep serving the previous version's tool/skill set otherwise. if err := b.toolboxes.PromoteToolboxVersion(ctx, toolboxName, created.Version); err != nil { - return "", fmt.Errorf("promoting toolbox %q to version %s: %w", toolboxName, created.Version, err) + return toolboxAttachment{}, fmt.Errorf("promoting toolbox %q to version %s: %w", toolboxName, created.Version, err) } - return b.mcpURL(created.Name, created.Version), nil + mcpURL := b.mcpURL(created.Name, created.Version) + connName, err := b.ensureToolboxConnection(ctx, created.Name, mcpURL) + if err != nil { + return toolboxAttachment{}, err + } + return toolboxAttachment{McpURL: mcpURL, ConnectionName: connName}, nil } -// ResolveToolbox confirms an existing toolbox and returns its MCP url. When the -// reference pins a version, the version-specific (developer) endpoint is used; -// otherwise the consumer endpoint that always serves the default_version. -func (b *foundryToolboxBuilder) ResolveToolbox(ctx context.Context, ref toolboxRef) (string, error) { +// ResolveToolbox confirms an existing toolbox and returns its MCP url plus the +// backing project connection. When the reference pins a version, the +// version-specific (developer) endpoint is used; otherwise the consumer +// endpoint that always serves the default_version. +func (b *foundryToolboxBuilder) ResolveToolbox(ctx context.Context, ref toolboxRef) (toolboxAttachment, error) { if _, err := b.toolboxes.GetToolbox(ctx, ref.Name); err != nil { - return "", fmt.Errorf("resolving toolbox %q: %w", ref.Name, err) + return toolboxAttachment{}, fmt.Errorf("resolving toolbox %q: %w", ref.Name, err) + } + mcpURL := b.mcpURL(ref.Name, ref.Version) + connName, err := b.ensureToolboxConnection(ctx, ref.Name, mcpURL) + if err != nil { + return toolboxAttachment{}, err } - return b.mcpURL(ref.Name, ref.Version), nil + return toolboxAttachment{McpURL: mcpURL, ConnectionName: connName}, nil +} + +// toolboxConnectionCategory is the Foundry connection category for a toolbox's +// MCP endpoint, consistent with the RemoteTool category used for MCP tools. +const toolboxConnectionCategory = "RemoteTool" + +// toolboxConnectionAuthType authenticates the agent to a toolbox hosted in the +// same Foundry project via the project's managed identity. +const toolboxConnectionAuthType = "ProjectManagedIdentity" + +// ensureToolboxConnection creates (or updates) a project connection that fronts +// the toolbox MCP endpoint and returns its name for use as the agent tool's +// project_connection_id. Without this connection the agent has no credential to +// reach the toolbox and its skills are never invoked. When no connections client +// is configured (e.g. missing ARM identifiers), it returns an empty name so +// callers degrade to a connection-less mcp tool rather than failing the deploy. +func (b *foundryToolboxBuilder) ensureToolboxConnection( + ctx context.Context, toolboxName, mcpURL string, +) (string, error) { + if b.connections == nil { + return "", nil + } + connName := toolboxConnectionName(toolboxName) + // Use the MCP endpoint without its query string as the connection target; + // the api-version belongs on the tool's server_url, not the connection. + target := mcpURL + if i := strings.IndexByte(target, '?'); i >= 0 { + target = target[:i] + } + if err := b.connections.UpsertProjectConnection( + ctx, b.resourceGroup, b.accountName, b.projectName, connName, + azure.ProjectConnectionProperties{ + Category: toolboxConnectionCategory, + Target: target, + AuthType: toolboxConnectionAuthType, + }, + ); err != nil { + return "", fmt.Errorf("creating toolbox connection %q: %w", connName, err) + } + return connName, nil +} + +// toolboxConnectionName derives a stable connection name for a toolbox. +func toolboxConnectionName(toolboxName string) string { + return toolboxName + "-toolbox" } // mcpURL builds the toolbox MCP endpoint. With a version it returns the @@ -443,9 +530,29 @@ func newFoundryToolboxBuilder(settings *PromptAgentSettings) (toolboxBuilder, er ) } cred := promptCredential() + // A control-plane connections client creates the connection that fronts the + // toolbox MCP endpoint (the data plane is read-only for connections). Parse + // the account/project from the endpoint; when the ARM identifiers are + // available the builder wires the connection, otherwise it degrades to a + // connection-less mcp tool (ensureToolboxConnection no-ops on a nil client). + var ( + connections *azure.FoundryConnectionsARMClient + accountName string + projectName string + ) + if account, project, err := parseAccountProject(settings.ProjectEndpoint); err == nil { + accountName, projectName = account, project + if strings.TrimSpace(settings.SubscriptionID) != "" && strings.TrimSpace(settings.ResourceGroup) != "" { + connections, _ = azure.NewFoundryConnectionsARMClient(settings.SubscriptionID, cred) + } + } return &foundryToolboxBuilder{ skills: azure.NewFoundrySkillsClient(settings.ProjectEndpoint, cred), toolboxes: azure.NewFoundryToolboxClient(settings.ProjectEndpoint, cred), + connections: connections, + resourceGroup: settings.ResourceGroup, + accountName: accountName, + projectName: projectName, projectEndpoint: settings.ProjectEndpoint, }, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go index 02e53b2ebf6..5caa6acf01d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go @@ -16,6 +16,7 @@ import ( // fakeToolboxBuilder records calls and returns a fixed MCP url. type fakeToolboxBuilder struct { mcpURL string + connName string ensureCalls int resolveCalls int lastSkills []skillBundle @@ -24,22 +25,22 @@ type fakeToolboxBuilder struct { func (b *fakeToolboxBuilder) EnsureToolbox( _ context.Context, _ string, skills []skillBundle, -) (string, error) { +) (toolboxAttachment, error) { b.ensureCalls++ b.lastSkills = skills if b.mcpURL == "" { b.mcpURL = "https://proj/toolboxes/agent/versions/1/mcp" } - return b.mcpURL, nil + return toolboxAttachment{McpURL: b.mcpURL, ConnectionName: b.connName}, nil } -func (b *fakeToolboxBuilder) ResolveToolbox(_ context.Context, ref toolboxRef) (string, error) { +func (b *fakeToolboxBuilder) ResolveToolbox(_ context.Context, ref toolboxRef) (toolboxAttachment, error) { b.resolveCalls++ b.lastRef = ref if b.mcpURL == "" { b.mcpURL = "https://proj/toolboxes/existing/versions/2/mcp" } - return b.mcpURL, nil + return toolboxAttachment{McpURL: b.mcpURL, ConnectionName: b.connName}, nil } func writeSkillsDir(t *testing.T, skills map[string]string) string { @@ -158,7 +159,7 @@ func TestScanSkillsDir_Empty(t *testing.T) { func TestInjectMcpTool_AddsWhenAbsent(t *testing.T) { managed := &agent_yaml.PromptAgent{} - injectMcpTool(managed, "toolbox-a", "https://proj/mcp") + injectMcpTool(managed, "toolbox-a", "https://proj/mcp", "toolbox-a-toolbox") if len(managed.Tools) != 1 { t.Fatalf("tools: got %d, want 1", len(managed.Tools)) @@ -167,6 +168,9 @@ func TestInjectMcpTool_AddsWhenAbsent(t *testing.T) { if tool["type"] != "mcp" || tool["server_url"] != "https://proj/mcp" { t.Errorf("tool: got %+v", tool) } + if tool["project_connection_id"] != "toolbox-a-toolbox" { + t.Errorf("project_connection_id: got %v, want toolbox-a-toolbox", tool["project_connection_id"]) + } } func TestInjectMcpTool_NotDuplicated(t *testing.T) { @@ -175,10 +179,14 @@ func TestInjectMcpTool_NotDuplicated(t *testing.T) { map[string]any{"type": "mcp", "server_url": "https://proj/mcp"}, }, } - injectMcpTool(managed, "toolbox-a", "https://proj/mcp") + injectMcpTool(managed, "toolbox-a", "https://proj/mcp", "toolbox-a-toolbox") if len(managed.Tools) != 1 { t.Errorf("expected no duplicate mcp tool, got %d", len(managed.Tools)) } + tool := managed.Tools[0].(map[string]any) + if tool["project_connection_id"] != "toolbox-a-toolbox" { + t.Errorf("expected connection id backfilled, got %v", tool["project_connection_id"]) + } } func TestToolboxNode_PrimaryRegistersSkills(t *testing.T) { @@ -212,6 +220,29 @@ func TestToolboxNode_PrimaryRegistersSkills(t *testing.T) { } } +func TestToolboxNode_InjectsConnectionID(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeToolboxBuilder{connName: "agent-toolbox"} + + skills := []skillBundle{{Dir: "s", Meta: skillMeta{ + Name: "s", Description: "d", Instructions: "do the thing", + }}} + node := toolboxNode(g, skills, nil, func() (toolboxBuilder, error) { return fake, nil }) + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if len(managed.Tools) != 1 { + t.Fatalf("expected 1 tool, got %d", len(managed.Tools)) + } + tool := managed.Tools[0].(map[string]any) + if tool["project_connection_id"] != "agent-toolbox" { + t.Errorf("project_connection_id: got %v, want agent-toolbox", tool["project_connection_id"]) + } +} + func TestToolboxNode_FallbackReferenceExisting(t *testing.T) { managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} managed.Name = "agent" diff --git a/cli/azd/extensions/azure.ai.agents/version.txt b/cli/azd/extensions/azure.ai.agents/version.txt index df402df9a1b..393ad52c480 100644 --- a/cli/azd/extensions/azure.ai.agents/version.txt +++ b/cli/azd/extensions/azure.ai.agents/version.txt @@ -1 +1 @@ -0.1.45-preview +0.1.46-preview diff --git a/cli/azd/extensions/registry.json b/cli/azd/extensions/registry.json index bb2b360b57b..faa0b484142 100644 --- a/cli/azd/extensions/registry.json +++ b/cli/azd/extensions/registry.json @@ -5337,6 +5337,88 @@ "version": "~0.0.1-preview" } ] + }, + { + "version": "0.1.46-preview", + "requiredAzdVersion": "\u003e1.25.2", + "capabilities": [ + "custom-commands", + "lifecycle-events", + "mcp-server", + "service-target-provider", + "metadata" + ], + "providers": [ + { + "name": "azure.ai.agent", + "type": "service-target", + "description": "Deploys agents to the Foundry Agent Service" + } + ], + "usage": "azd ai agent \u003ccommand\u003e [options]", + "examples": [ + { + "name": "init", + "description": "Initialize a new AI agent project.", + "usage": "azd ai agent init" + } + ], + "artifacts": { + "darwin/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "3c5f650176360ec740f48b91f0e4a801c31e14fa2b7cca5ad3fce1f3ccf1d251" + }, + "entryPoint": "azure-ai-agents-darwin-amd64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.46-preview/azure-ai-agents-darwin-amd64.zip" + }, + "darwin/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "8095cc541172c495763434a82cbea9ae453155803cce42aabab77d725afca33a" + }, + "entryPoint": "azure-ai-agents-darwin-arm64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.46-preview/azure-ai-agents-darwin-arm64.zip" + }, + "linux/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "e7de710858f6eb4f07a70f9f6ff10cb364d7ba8defe8ef1d40cf68f0be541ac2" + }, + "entryPoint": "azure-ai-agents-linux-amd64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.46-preview/azure-ai-agents-linux-amd64.tar.gz" + }, + "linux/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "c640f60264a3827138165781dbf6751df21e94ff33b5dcc8771533b04eb5171e" + }, + "entryPoint": "azure-ai-agents-linux-arm64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.46-preview/azure-ai-agents-linux-arm64.tar.gz" + }, + "windows/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "9b88d62d2b242446d7c6bd4665483d85c1246f34a3d24172a2db6a6a34cb7ec6" + }, + "entryPoint": "azure-ai-agents-windows-amd64.exe", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.46-preview/azure-ai-agents-windows-amd64.zip" + }, + "windows/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "07fde68bb266caf3d1a0c2a085fe4d5b67e957f11628989306bdd24e9acc2f6e" + }, + "entryPoint": "azure-ai-agents-windows-arm64.exe", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.46-preview/azure-ai-agents-windows-arm64.zip" + } + }, + "dependencies": [ + { + "id": "azure.ai.inspector", + "version": "~0.0.1-preview" + } + ] } ] }, From 71500985af92f9588f7e279c4ee79292d40423c1 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Mon, 27 Jul 2026 03:11:08 -0700 Subject: [PATCH 14/24] merge fixes --- .../azure.ai.agents/internal/cmd/listen.go | 43 ++++--------------- .../pkg/azure/foundry_toolsets_client.go | 1 - .../pkg/azure/foundry_toolsets_client_test.go | 1 - .../internal/project/service_target_agent.go | 6 +-- 4 files changed, 11 insertions(+), 40 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index d78d01ae50b..b8f41ed496e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -88,13 +88,14 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args _, isPrompt := promptSettingsFromService(svc) if !isPrompt { if err := prepareContainerSettings( - ctx, - azdClient, - svc, - args.Project.Path, - ); err != nil { - return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) - } + ctx, + azdClient, + svc, + args.Project.Path, + ); err != nil { + return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) + } + } if err := envUpdate( ctx, azdClient, @@ -145,22 +146,6 @@ func postprovisionHandler( hasAgent = true break } - - // Prompt (kind=managed) agents have no toolboxes to provision on a - // Foundry project — the harness owns those. Skip toolbox provisioning - // but still treat the project as having an agent (for the - // pending-provision signal clear below). - if _, isPrompt := promptSettingsFromService(svc); isPrompt { - continue - } - - if err := provisionToolboxes(ctx, azdClient, svc); err != nil { - return fmt.Errorf( - "failed to provision toolboxes for service %q: %w", - svc.Name, err, - ) - - } } // Clear the AI_AGENT_PENDING_PROVISION signal now that provision has @@ -238,18 +223,6 @@ func updateLegacyProjectDeployments( return nil } - // Prompt (kind=managed) agents have no container settings and no - // developer-RBAC pre-flight — the harness owns the runtime. - if _, isPrompt := promptSettingsFromService(svc); isPrompt { - continue - } - - if err := populateContainerSettings(ctx, azdClient, svc); err != nil { - return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) - } - if err := envUpdate(ctx, azdClient, args.Project, svc); err != nil { - return fmt.Errorf("failed to update environment for service %q: %w", svc.Name, err) - } envName, err := currentEnvName(ctx, azdClient) if err != nil { return fmt.Errorf( diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go index 45044a1cd11..d8ccd3ff3ef 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go @@ -241,7 +241,6 @@ func (c *FoundryToolboxClient) PromoteToolboxVersion( if err != nil { return fmt.Errorf("failed to create request: %w", err) } - req.Raw().Header.Set("Foundry-Features", toolboxesFeatureHeader) if err := req.SetBody( streaming.NopCloser(bytes.NewReader(payload)), "application/json", diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go index 61217a4b17f..e20bdf44c7e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go @@ -308,7 +308,6 @@ func TestPromoteToolboxVersion_RequestShape(t *testing.T) { require.Equal(t, http.MethodPatch, captured.Method) require.Equal(t, "/toolboxes/tb", captured.URL.EscapedPath()) require.Equal(t, "api-version="+toolboxesApiVersion, captured.URL.RawQuery) - require.Equal(t, toolboxesFeatureHeader, captured.Header.Get("Foundry-Features")) require.Contains(t, string(body), `"default_version":"v2"`) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index bcbe733f54d..b195b98113c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -240,7 +240,7 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er // Foundry project. They self-authenticate via the harness client and carry // their entire deploy target in the service config, so skip the // subscription/tenant/credential resolution the hosted path needs. - if serviceIsPromptAgent(serviceConfig) { + if serviceIsPromptAgent(p.serviceConfig) { fmt.Fprintf(os.Stderr, "Project path: %s, Service path: %s\n", proj.Project.Path, fullPath) return p.resolveAgentDefinitionPath(proj.Project.Path, servicePath, fullPath) } @@ -335,7 +335,7 @@ func (p *AgentServiceTargetProvider) resolveAgentDefinitionPath( // so no on-disk agent.yaml is required. if _, _, found, _, defErr := AgentDefinitionFromResolvedService( p.serviceConfig, - proj.Project.Path, + projectPath, ); defErr != nil { return defErr } else if found { @@ -344,7 +344,7 @@ func (p *AgentServiceTargetProvider) resolveAgentDefinitionPath( } // Legacy shape: look for agent.yaml or agent.yml in the service directory root - agentYamlPath, err := paths.JoinAllowRoot(proj.Project.Path, servicePath, "agent.yaml") + agentYamlPath, err := paths.JoinAllowRoot(projectPath, servicePath, "agent.yaml") if err != nil { return exterrors.Validation( exterrors.CodeInvalidServiceConfig, From 7b51fb03de15f8c010508fcf8ae76e686c8de14e Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Fri, 21 Aug 2026 14:22:27 +0530 Subject: [PATCH 15/24] prompt and managed agents --- .../internal/cmd/agent_endpoint.go | 11 +- .../azure.ai.agents/internal/cmd/delete.go | 17 +- .../azure.ai.agents/internal/cmd/init.go | 124 +++-- .../internal/cmd/init_adopt.go | 13 +- .../internal/cmd/init_adopt_test.go | 37 +- .../internal/cmd/init_from_code.go | 12 +- .../cmd/init_from_templates_helpers.go | 115 ++++- .../cmd/init_from_templates_helpers_test.go | 101 ++++ .../internal/cmd/init_managed.go | 475 ++++++++++++++---- .../internal/cmd/init_managed_foundry.go | 157 ++++-- .../cmd/init_managed_manifest_test.go | 231 +++++++++ .../internal/cmd/init_managed_test.go | 58 +-- .../internal/cmd/invoke_managed.go | 56 ++- .../cmd/invoke_managed_stream_test.go | 93 ++++ .../azure.ai.agents/internal/cmd/listen.go | 83 ++- .../internal/cmd/prompt_service.go | 24 + .../internal/cmd/resource_services.go | 121 +++-- .../internal/cmd/resource_services_test.go | 103 +--- .../azure.ai.agents/internal/cmd/show.go | 4 +- .../azure.ai.agents/internal/cmd/show_test.go | 6 +- .../internal/exterrors/codes.go | 4 +- .../agents/agent_api/managed_operations.go | 28 +- .../internal/pkg/agents/agent_api/models.go | 80 ++- .../pkg/agents/agent_yaml/managed_test.go | 154 +++++- .../internal/pkg/agents/agent_yaml/map.go | 94 +++- .../internal/pkg/agents/agent_yaml/parse.go | 14 +- .../pkg/agents/agent_yaml/prompt_features.go | 230 +++++++++ .../agents/agent_yaml/prompt_features_test.go | 390 ++++++++++++++ .../agents/agent_yaml/prompt_harness_gate.go | 158 ++++++ .../agent_yaml/prompt_harness_gate_test.go | 205 ++++++++ .../agents/agent_yaml/prompt_schema_test.go | 3 +- .../pkg/agents/agent_yaml/prompt_tools.go | 148 ++++++ .../agents/agent_yaml/prompt_tools_test.go | 198 ++++++++ .../pkg/agents/agent_yaml/samples_test.go | 116 +++++ .../internal/pkg/agents/agent_yaml/yaml.go | 144 +++++- .../pkg/azure/foundry_files_client.go | 42 ++ .../pkg/azure/foundry_toolsets_client.go | 125 ----- .../pkg/azure/foundry_toolsets_client_test.go | 152 ------ .../project/agent_manifest_ref_test.go | 159 ++++++ .../internal/project/memory_store.go | 163 ++++++ .../internal/project/prompt_client.go | 77 ++- .../internal/project/prompt_client_test.go | 114 ++++- .../internal/project/prompt_connections.go | 76 ++- .../project/prompt_connections_creds_test.go | 37 ++ .../project/prompt_connections_test.go | 13 +- .../project/prompt_convention_test.go | 102 ++-- .../internal/project/prompt_deployment.go | 21 +- .../internal/project/prompt_files.go | 97 +++- .../internal/project/prompt_files_test.go | 16 +- .../internal/project/prompt_graph.go | 180 ++++++- .../project/prompt_graph_warnings_test.go | 230 +++++++++ .../internal/project/prompt_memory.go | 237 +++++++++ .../internal/project/prompt_memory_test.go | 223 ++++++++ .../internal/project/prompt_skills.go | 366 ++++++++++---- .../internal/project/prompt_skills_test.go | 401 +++++++++++++-- .../internal/project/service_target_agent.go | 221 +++++--- .../internal/project/service_target_prompt.go | 263 +++++++--- .../service_target_prompt_errors_test.go | 84 ++++ .../internal/project/workspace_create.go | 20 +- .../internal/synthesis/synthesizer.go | 47 +- .../internal/synthesis/synthesizer_test.go | 61 +++ .../extensions/azure.ai.projects/CHANGELOG.md | 6 + .../foundry_provisioning_provider.go | 44 +- .../foundry_provisioning_provider_test.go | 38 ++ .../resource_group_location_check.go | 1 + .../internal/synthesis/synthesizer.go | 47 +- .../internal/synthesis/synthesizer_test.go | 30 +- 67 files changed, 6317 insertions(+), 1183 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_stream_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_tools.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_tools_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/samples_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/agent_manifest_ref_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/memory_store.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_creds_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph_warnings_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_memory.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_memory_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt_errors_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go index 3daa60f8f8d..761b7e7de99 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go @@ -167,7 +167,16 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { apiVersion = values[0] } - projectEndpoint := fmt.Sprintf("https://%s/api/projects/%s", host, projectSegment) + // Rebuild the project-scoped endpoint. On the override path preserve the + // caller's scheme and host:port verbatim: forcing https and dropping the port + // would rewrite an `http://localhost:5000` override to `https://localhost/...` + // and never reach the local backend the override exists to target. This + // mirrors what validateProjectEndpoint does in project_endpoint.go. + scheme, authority := "https", host + if bypass { + scheme, authority = strings.ToLower(u.Scheme), u.Host + } + projectEndpoint := fmt.Sprintf("%s://%s/api/projects/%s", scheme, authority, projectSegment) return &parsedAgentEndpoint{ ProjectEndpoint: projectEndpoint, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go index 74d3c709959..451b3bcf780 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go @@ -104,7 +104,12 @@ func (a *DeleteAction) Run(ctx context.Context) error { // rather than calling the Foundry agent-delete path that would fail. if pctx, isPrompt, pErr := resolvePromptAgentService( ctx, azdClient, a.flags.name, a.flags.noPrompt, - ); pErr == nil && isPrompt { + ); pErr != nil { + // Match `show`: a failure to resolve is a real error. Falling through to + // the hosted path would run the Foundry agent-delete flow against a + // prompt agent and report a misleading "agent not found". + return pErr + } else if isPrompt { return a.runPromptDelete(ctx, azdClient, pctx) } @@ -237,6 +242,7 @@ func (a *DeleteAction) cleanupEnvVars(ctx context.Context, azdClient *azdext.Azd fmt.Sprintf("AGENT_%s_NAME", serviceKey), fmt.Sprintf("AGENT_%s_VERSION", serviceKey), fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey), + fmt.Sprintf("AGENT_%s_VECTOR_STORE_ID", serviceKey), } for _, key := range keys { @@ -343,6 +349,15 @@ func (a *DeleteAction) runPromptDelete( return classifyDeleteError(err, agentName) } + // Same post-delete cleanup as the hosted path: without it the stale + // AGENT_{KEY}_* values keep `show`/`invoke` pointed at an agent that no + // longer exists. Session state must be cleared first since it reads + // AGENT_{KEY}_ENDPOINT. + if envResp, envErr := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); envErr == nil { + cleanupAgentSessionState(ctx, azdClient, envResp.Environment.Name, pctx.ServiceName) + } + a.cleanupEnvVars(ctx, azdClient, pctx.ServiceName) + switch a.flags.output { case "json": data, jsonErr := json.MarshalIndent(result, "", " ") diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 3825faf011e..9f697d7dd4c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -77,11 +77,16 @@ type initFlags struct { // mirrors the `--force` convention used by `azd down`, `azd env remove`, // `azd config reset`, and `azd infra generate`. force bool - // kind, when set, explicitly selects the agent runtime ("hosted" or - // "managed") and bypasses the interactive kind prompt. This is primarily - // for non-interactive callers (--no-prompt) and automation; interactive - // users get the kind prompt when this is empty. + // kind, when set, explicitly selects the agent runtime ("hosted", + // "prompt", or "managed") and bypasses the interactive kind prompt. This is + // primarily for non-interactive callers (--no-prompt) and automation; + // interactive users get the kind prompt when this is empty. kind string + // harness, when set, names the execution harness written to the scaffolded + // prompt agent.yaml (only "github-copilot" is supported today). It overrides + // the harness implied by --kind, so `--kind prompt --harness github-copilot` + // is equivalent to `--kind managed`. Ignored for hosted agents. + harness string // noPrompt is resolved from the extension context (--no-prompt / AZD_NO_PROMPT) // and is not registered as a CLI flag on the init command itself. noPrompt bool @@ -1065,7 +1070,9 @@ When -m points at a sample's unified azure.yaml (a project manifest that declares services with host: azure.ai.project / azure.ai.agent / ...), that azure.yaml is adopted as the project manifest and its referenced files are placed at the project root. When -m points at an agent manifest instead, the -project's azure.yaml is generated from it. +project's azure.yaml is generated from it. An agent manifest that declares +kind: prompt scaffolds a prompt agent (or a managed agent when it also declares +harness: github-copilot), carrying over its model, instructions, skills, and tools. The agent name written to agent.yaml is the Foundry agent identity. Foundry agents are unique by name within a project, so deploying with an existing name @@ -1093,6 +1100,16 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, azd ai agent init --no-prompt --project-id "" \ --deploy-mode code --runtime python_3_13 --entry-point app.py + # Non-interactive prompt agent against an existing Foundry project + azd ai agent init --no-prompt --kind prompt --agent-name my-agent \ + --project-id "" --model-deployment gpt-4.1-mini + + # Non-interactive managed agent that provisions a new Foundry project and model + azd ai agent init --no-prompt --kind managed --agent-name my-agent --model gpt-4.1-mini + + # Non-interactive prompt agent from a prompt agent template + azd ai agent init --no-prompt -m ./agent.yaml --project-id "" + # Bring your own pre-built image (no template/language selection, Dockerfile, or ACR setup) azd ai agent init --no-prompt --agent-name my-agent \ --image myacr.azurecr.io/agents/my-agent:v1`, @@ -1182,23 +1199,56 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // otherwise-blank invocation can branch into the prompt-agent flow. // // An explicit --kind flag always wins: it bypasses both the prompt - // and the hosted-signal gating so automation can select the - // prompt-agent runtime non-interactively. "managed" is accepted as - // a backward-compatible alias for "prompt". - if flags.kind != "" { - switch agentKindChoice(strings.ToLower(strings.TrimSpace(flags.kind))) { - case AgentKindChoicePrompt, AgentKindChoiceManaged: - return runInitManaged(ctx, flags, azdClient) - case AgentKindChoiceHosted: - // Fall through to the hosted flow below. - default: - return exterrors.Validation( - exterrors.CodeInvalidParameter, - fmt.Sprintf("unknown --kind value %q", flags.kind), - "supported values are: hosted, prompt", - ) + // and the hosted-signal gating so automation can select a + // prompt-agent runtime non-interactively. Both prompt kinds share the + // same init flow and the same agent.yaml `kind: prompt`; they differ + // only in the harness written to the manifest. + // + // A supplied --manifest (or positional template) that declares + // `kind: prompt` also routes here, with or without --kind, so a + // prompt-agent template scaffolds a prompt agent instead of being + // mis-handled by the hosted generator. `--kind hosted` opts out of + // that peek entirely so the hosted path never pays for an extra + // fetch of a remote pointer. + requestedKind := agentKindChoice(strings.ToLower(strings.TrimSpace(flags.kind))) + if flags.kind != "" && + requestedKind != AgentKindChoiceHosted && + requestedKind != AgentKindChoicePrompt && + requestedKind != AgentKindChoiceManaged { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("unknown --kind value %q", flags.kind), + "supported values are: hosted, prompt, managed", + ) + } + + var promptManifest *promptAgentManifest + if requestedKind != AgentKindChoiceHosted { + promptManifest, err = loadPromptManifestFromPointer(ctx, azdClient, flags, httpClient) + if err != nil { + return err } - } else { + } + + switch { + case requestedKind == AgentKindChoicePrompt || requestedKind == AgentKindChoiceManaged: + harness, harnessErr := resolveInitHarness(flags.harness, requestedKind) + if harnessErr != nil { + return harnessErr + } + return runInitManaged(ctx, flags, azdClient, harness, promptManifest) + case promptManifest != nil: + // No --kind: the manifest's own harness decides the flavor, so a + // `harness: github-copilot` template scaffolds a managed agent and a + // harness-less one a plain prompt agent. --harness still wins. + harness, harnessErr := resolveManifestInitHarness( + flags.harness, promptManifest.definition.Harness, + ) + if harnessErr != nil { + return harnessErr + } + return runInitManaged(ctx, flags, azdClient, harness, promptManifest) + case flags.kind == "": hostedSignalsPresent := userProvidedManifest || flags.src != "" || flags.deployMode != "" || @@ -1210,7 +1260,11 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, return kindErr } if kindChoice == AgentKindChoicePrompt || kindChoice == AgentKindChoiceManaged { - return runInitManaged(ctx, flags, azdClient) + harness, harnessErr := resolveInitHarness(flags.harness, kindChoice) + if harnessErr != nil { + return harnessErr + } + return runInitManaged(ctx, flags, azdClient, harness, nil) } } } @@ -1596,7 +1650,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Name of the AI model to use (e.g., 'gpt-4o'). If not specified, defaults to 'gpt-4.1-mini'. Mutually exclusive with --model-deployment, with --model-deployment being used if both are provided") cmd.Flags().StringVarP(&flags.manifestPointer, "manifest", "m", "", - "Path or URI to an agent manifest, or to a sample's unified azure.yaml to adopt as the project manifest") + "Path or URI to an agent manifest (hosted or 'kind: prompt'), or to a sample's unified azure.yaml to adopt as the project manifest") cmd.Flags().StringVar(&flags.agentName, "agent-name", "", "Foundry agent name to write to agent.yaml. Reusing a name creates a new version of the existing agent.") @@ -1633,9 +1687,15 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Required together with --no-prompt when init would otherwise need confirmation.") cmd.Flags().StringVar(&flags.kind, "kind", "", - "Agent runtime to initialize: 'hosted' (bring your own code/container) or 'prompt' "+ - "(model + instructions; Foundry runs Brain+Hand, Harness: GHCP). When omitted, you are "+ - "prompted interactively.") + "Agent runtime to initialize: 'hosted' (bring your own code/container), 'prompt' "+ + "(model + instructions; Foundry runs the agent directly, no harness), or 'managed' "+ + "(a prompt agent that additionally runs on the GitHub Copilot Brain+Hand harness). When omitted, "+ + "the kind is taken from --manifest when it declares one, otherwise you are prompted "+ + "interactively. With --no-prompt, 'prompt' and 'managed' require --agent-name and "+ + "either --model or --model-deployment (unless supplied by --manifest).") + cmd.Flags().StringVar(&flags.harness, "harness", "", + "Execution harness for a prompt agent: 'github-copilot' (GitHub Copilot Brain+Hand) or 'none'. "+ + "Overrides the harness implied by --kind. Ignored for hosted agents.") cmd.Flags().StringVar(&flags.infra, "infra", "", "Eject infrastructure-as-code from azure.yaml into ./infra/. "+ "A bare --infra ejects Bicep; --infra=terraform ejects Terraform and sets "+ @@ -3024,11 +3084,17 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa // Emit the sibling Foundry resource services (project + deployments, // connections, toolboxes) and wire the agent's uses: to them. A selected - // existing project contributes its endpoint so provision reuses it. + // existing project contributes its endpoint so provision reuses it. The + // endpoint itself lives in the azd environment; azure.yaml only references it. + endpointRef, err := recordFoundryProjectEnv( + ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject, + ) + if err != nil { + return err + } if err := emitResourceServices( ctx, a.azdClient, a.serviceNameOverride, - projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), - a.selectedFoundryProject.Endpoint(), + endpointRef, resourceDeployments, resourceConnections, resourceToolboxes, ); err != nil { return err diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 173163287fe..b6322335cfa 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -917,11 +917,16 @@ func runInitFromAzureYaml( return err } - // When an existing project was selected, stamp its endpoint onto the - // azure.ai.project service so the provisioning provider recognizes the - // brownfield signal and reuses the project instead of creating a new one. + // When an existing project was selected, record its endpoint in the azd + // environment and stamp the portable reference onto the azure.ai.project + // service so the provisioning provider recognizes the brownfield signal and + // reuses the project instead of creating a new one. if result.FoundryProject != nil { - if err := stampProjectEndpoint(ctx, azdClient, result.FoundryProject); err != nil { + endpointRef, err := recordFoundryProjectEnv(ctx, azdClient, env.Name, result.FoundryProject) + if err != nil { + return err + } + if err := stampProjectEndpoint(ctx, azdClient, endpointRef); err != nil { return err } if err := confirmAdoptedAgentNameConflicts( diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go index aa697dbd69c..69fc2d07477 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go @@ -803,8 +803,8 @@ func TestUpdateAdoptedAgentNames_UnchangedNamesAreNotWritten(t *testing.T) { } // TestStampProjectEndpoint_WritesEndpoint verifies that stampProjectEndpoint -// writes the endpoint to the existing azure.ai.project service via -// SetServiceConfigValue when a valid project is provided. +// writes the portable endpoint reference to the existing azure.ai.project +// service via SetServiceConfigValue. func TestStampProjectEndpoint_WritesEndpoint(t *testing.T) { t.Parallel() @@ -815,32 +815,20 @@ func TestStampProjectEndpoint_WritesEndpoint(t *testing.T) { } client := newProjectRecorderClient(t, server) - selectedProject := &FoundryProjectInfo{ - AccountName: "myaccount", - ProjectName: "myproject", - } - - err := stampProjectEndpoint(t.Context(), client, selectedProject) + err := stampProjectEndpoint(t.Context(), client, projectEndpointRef) require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() - // The recording server captures SetServiceConfigValue calls in uses map - // for "uses" path, but for "endpoint" we check the raw call was made by - // verifying through the actual project state. Since recordingProjectServer - // returns success, we verify the function didn't error and the endpoint - // would have been written. For a deeper assertion, check the call was made - // with the correct service name and value by inspecting configValues. + // azure.yaml gets the ${VAR} reference, never the literal URL: the concrete + // endpoint lives in the azd environment so the project stays portable. require.Equal(t, "ai-project", server.configValues["endpoint"].serviceName) - require.Equal(t, - "https://myaccount.services.ai.azure.com/api/projects/myproject", - server.configValues["endpoint"].value, - ) + require.Equal(t, "${AZURE_AI_PROJECT_ENDPOINT}", server.configValues["endpoint"].value) } // TestStampProjectEndpoint_NilProject verifies stampProjectEndpoint is a no-op -// when the selected project is nil (user chose "Create new"). +// when there is no endpoint to stamp (user chose "Create new"). func TestStampProjectEndpoint_NilProject(t *testing.T) { t.Parallel() @@ -851,12 +839,12 @@ func TestStampProjectEndpoint_NilProject(t *testing.T) { } client := newProjectRecorderClient(t, server) - err := stampProjectEndpoint(t.Context(), client, nil) + err := stampProjectEndpoint(t.Context(), client, "") require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() - require.Empty(t, server.configValues, "no SetServiceConfigValue calls expected for nil project") + require.Empty(t, server.configValues, "no SetServiceConfigValue calls expected without an endpoint") } // TestStampProjectEndpoint_NoExistingService verifies stampProjectEndpoint is a @@ -871,12 +859,7 @@ func TestStampProjectEndpoint_NoExistingService(t *testing.T) { } client := newProjectRecorderClient(t, server) - selectedProject := &FoundryProjectInfo{ - AccountName: "myaccount", - ProjectName: "myproject", - } - - err := stampProjectEndpoint(t.Context(), client, selectedProject) + err := stampProjectEndpoint(t.Context(), client, projectEndpointRef) require.NoError(t, err) server.mu.Lock() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index 6f6ceef87c8..ec55c4a263b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -871,12 +871,18 @@ func (a *InitFromCodeAction) addToProject( // Emit the sibling azure.ai.project service carrying the model deployments // and wire the agent's uses: to it. A selected existing project contributes - // its endpoint so provision reuses it instead of creating a new project. + // its endpoint so provision reuses it instead of creating a new project. The + // endpoint itself lives in the azd environment; azure.yaml only references it. agentServiceName := strings.ReplaceAll(agentName, " ", "") + endpointRef, err := recordFoundryProjectEnv( + ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject, + ) + if err != nil { + return err + } if err := emitResourceServices( ctx, a.azdClient, agentServiceName, - projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), - a.selectedFoundryProject.Endpoint(), + endpointRef, resourceDeployments, nil, nil, ); err != nil { return err diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go index 9ec2cadfd14..1cea68f516c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go @@ -18,10 +18,12 @@ import ( "strings" "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/fatih/color" ) const agentTemplatesURL = "https://aka.ms/foundry-agents-samples" @@ -99,9 +101,10 @@ const ( ) // agentKindChoice represents the discriminator the user picks at the very -// start of `azd ai agent init`. It selects between the two supported agent -// runtimes: hosted (today's container/code-deploy flow) and prompt (the -// Foundry Brain+Hand harness, currently powered by GitHub Copilot / GHCP). +// start of `azd ai agent init`. It selects between the supported agent +// runtimes: hosted (the container/code-deploy flow), prompt (a plain Foundry +// prompt agent with no harness), and managed (a prompt agent driven by the +// Foundry Brain+Hand harness, currently powered by GitHub Copilot). type agentKindChoice string const ( @@ -109,19 +112,68 @@ const ( // supplies code or a container image and the platform runs it on Azure // Container Apps. AgentKindChoiceHosted agentKindChoice = "hosted" - // AgentKindChoicePrompt is the "prompt" agent path — the customer declares - // model + instructions and the Foundry harness (GHCP) runs Brain+Hand on - // demand. The scaffolded agent.yaml uses kind: prompt (see - // agent_yaml.AgentKindPrompt), matching this choice value exactly. + // AgentKindChoicePrompt is the plain "prompt" agent path — the customer + // declares model + instructions and Foundry runs the agent directly, with + // no harness and no sandbox to provision. The scaffolded agent.yaml uses + // kind: prompt (see agent_yaml.AgentKindPrompt) and omits `harness`. AgentKindChoicePrompt agentKindChoice = "prompt" - // AgentKindChoiceManaged is a backward-compatible alias for - // AgentKindChoicePrompt accepted on the --kind flag. Prefer "prompt". + // AgentKindChoiceManaged is the managed-agent path — a prompt agent that + // additionally names an execution harness (GitHub Copilot), so Foundry + // provisions a Brain+Hand sandbox for it. The scaffolded agent.yaml still + // uses kind: prompt; the only difference is `harness: github-copilot`. AgentKindChoiceManaged agentKindChoice = "managed" ) +// harnessForKindChoice returns the agent.yaml `harness` value implied by a kind +// choice. Managed agents run on the GitHub Copilot harness; plain prompt agents +// have none, so the field is omitted from agent.yaml and the create request. +func harnessForKindChoice(choice agentKindChoice) string { + if choice == AgentKindChoiceManaged { + return agent_api.ManagedAgentHarnessGitHubCopilot + } + return "" +} + +// harnessNone is the --harness value that explicitly opts out of a harness, +// letting `--kind managed --harness none` degrade to a plain prompt agent. +const harnessNone = "none" + +// resolveInitHarness resolves the harness written to the scaffolded agent.yaml. +// An explicit --harness value always wins over the harness implied by the kind +// choice, so `--kind prompt --harness github-copilot` and `--kind managed` are +// equivalent. +func resolveInitHarness(harnessFlag string, choice agentKindChoice) (string, error) { + harness := strings.ToLower(strings.TrimSpace(harnessFlag)) + switch harness { + case "": + return harnessForKindChoice(choice), nil + case harnessNone: + return "", nil + case agent_api.ManagedAgentHarnessGitHubCopilot: + return agent_api.ManagedAgentHarnessGitHubCopilot, nil + case agent_api.ManagedAgentHarnessGitHubCopilotRemoved: + // Named separately from the generic "unknown value" case so the error + // tells the user what to type instead of only what is allowed. + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf( + "--harness %q is no longer accepted", + agent_api.ManagedAgentHarnessGitHubCopilotRemoved, + ), + fmt.Sprintf("use --harness %s instead", agent_api.ManagedAgentHarnessGitHubCopilot), + ) + default: + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("unknown --harness value %q", harnessFlag), + fmt.Sprintf("supported values are: %s, %s", agent_api.ManagedAgentHarnessGitHubCopilot, harnessNone), + ) + } +} + // promptAgentKind asks the user which agent kind to initialize. In no-prompt -// mode it returns AgentKindChoiceHosted to preserve today's behaviour for CI -// callers that do not yet know about the new kind. The selection is the very +// mode it returns AgentKindChoiceHosted to preserve today's behavior for CI +// callers that do not yet know about the new kinds. The selection is the very // first interactive prompt in `azd ai agent init` and routes the rest of the // init flow. func promptAgentKind( @@ -135,19 +187,25 @@ func promptAgentKind( choices := []*azdext.SelectChoice{ { - Label: "Hosted agent — bring your own code or container (deployed to Azure Container Apps)", + Label: "Hosted agent — Bring your own code or framework", Value: string(AgentKindChoiceHosted), }, { - Label: "Prompt agent — model + instructions only (Foundry runs Brain+Hand; Harness: GHCP)", + Label: "Prompt agent (no code, Foundry-managed) — " + + "Configure a model, instructions, and tools", Value: string(AgentKindChoicePrompt), }, + { + Label: "Prompt agent with GitHub Copilot harness (preview) — " + + "Configure a model, instructions, tools, and skills", + Value: string(AgentKindChoiceManaged), + }, } defaultIndex := int32(0) resp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ Options: &azdext.SelectOptions{ - Message: "What kind of agent do you want to initialize?", + Message: "What type of agent do you want to initialize?", Choices: choices, SelectedIndex: &defaultIndex, }, @@ -159,7 +217,34 @@ func promptAgentKind( return "", fmt.Errorf("failed to prompt for agent kind: %w", err) } - return agentKindChoice(choices[*resp.Value].Value), nil + choice := agentKindChoice(choices[*resp.Value].Value) + warnPromptAgentPreview(os.Stdout, choice) + return choice, nil +} + +// warnPromptAgentPreview tells the user that prompt-agent support in azd is +// still in preview. The harnessed option already carries "(preview)" in its +// label, so only the plain prompt agent needs the callout; without it that +// option reads as generally available next to the hosted one. +// +// This warns rather than blocks: preview is a stability signal, not a gate. +func warnPromptAgentPreview(writer io.Writer, choice agentKindChoice) { + if choice != AgentKindChoicePrompt { + return + } + + // Each segment is colored independently. Nesting output.WithBold inside + // output.WithWarningFormat would emit a reset mid-string, dropping the + // surrounding yellow and switching the foreground to white from there on. + emphasis := color.New(color.FgYellow, color.Bold) + + fmt.Fprintf(writer, "%s%s%s", + output.WithWarningFormat("\n(!) Prompt agents are a "), + emphasis.Sprint("preview feature of the azd CLI experience"), + output.WithWarningFormat( + ". The authoring layout and commands may change in a future release.\n\n", + ), + ) } // promptInitMode asks the user whether to use existing code or start from a template. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go index 7c7faf0b43f..8e93ec2800e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go @@ -4,6 +4,7 @@ package cmd import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -11,9 +12,109 @@ import ( "path/filepath" "testing" + "azureaiagent/internal/pkg/agents/agent_api" + "github.com/stretchr/testify/require" ) +func TestResolveInitHarness(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + harnessFlag string + choice agentKindChoice + expected string + expectErr bool + }{ + { + name: "prompt kind has no harness", + choice: AgentKindChoicePrompt, + expected: "", + }, + { + name: "managed kind implies the github-copilot harness", + choice: AgentKindChoiceManaged, + expected: agent_api.ManagedAgentHarnessGitHubCopilot, + }, + { + name: "explicit harness overrides prompt kind", + harnessFlag: "GitHub-Copilot", + choice: AgentKindChoicePrompt, + expected: agent_api.ManagedAgentHarnessGitHubCopilot, + }, + { + // The old abbreviation is rejected rather than silently upgraded so + // the user learns the new spelling instead of keeping a value the + // service no longer knows. + name: "removed ghcp spelling is rejected", + harnessFlag: "ghcp", + choice: AgentKindChoicePrompt, + expectErr: true, + }, + { + name: "none opts out of the managed harness", + harnessFlag: " none ", + choice: AgentKindChoiceManaged, + expected: "", + }, + { + name: "unknown harness is rejected", + harnessFlag: "bogus", + choice: AgentKindChoicePrompt, + expectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + harness, err := resolveInitHarness(tc.harnessFlag, tc.choice) + if tc.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.expected, harness) + }) + } +} + +// TestWarnPromptAgentPreview verifies the preview callout fires for the plain +// prompt agent and stays silent for the other kinds. The harnessed option +// already says "(preview)" in its label, and hosted agents are GA. +func TestWarnPromptAgentPreview(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + choice agentKindChoice + wantWarn bool + }{ + {name: "prompt agent warns", choice: AgentKindChoicePrompt, wantWarn: true}, + {name: "managed agent stays quiet", choice: AgentKindChoiceManaged}, + {name: "hosted agent stays quiet", choice: AgentKindChoiceHosted}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + buf := &bytes.Buffer{} + warnPromptAgentPreview(buf, tc.choice) + + if !tc.wantWarn { + require.Empty(t, buf.String()) + return + } + // The emphasized phrase is a separately colored segment, so assert + // it survives concatenation intact rather than being split. + require.Contains(t, buf.String(), "preview feature of the azd CLI experience") + }) + } +} + func TestEffectiveType(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go index 7e667c8cb25..5c0dfd59cae 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go @@ -8,6 +8,7 @@ import ( "context" "fmt" "log" + "net/http" "os" "path/filepath" "strings" @@ -20,48 +21,217 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/fatih/color" "go.yaml.in/yaml/v3" + "google.golang.org/protobuf/types/known/structpb" ) +// promptAgentManifestFileName is the manifest filename `init` scaffolds. It is +// also written into azure.yaml as the service's `manifest:` value, so the link +// between the service and its manifest is visible in the project file rather +// than implied by a filename azd happens to look for. +const promptAgentManifestFileName = "agent.yaml" + +// promptAgentManifest is a prompt-agent definition supplied through +// `--manifest` (or a positional template pointer), pre-loaded so runInitManaged +// can seed the scaffold from it instead of prompting for each field. +// +// sourceDir is the directory the manifest was read from. When the manifest is +// local, a sibling instructions file is used as the agent's instructions, which +// keeps a template's authoring layout intact instead of collapsing it to the +// default stub. +type promptAgentManifest struct { + definition agent_yaml.PromptAgent + sourceDir string +} + +// agentName returns the manifest's agent name, trimmed. Empty when unset. +func (m *promptAgentManifest) agentName() string { + if m == nil { + return "" + } + return strings.TrimSpace(m.definition.Name) +} + +// model returns the manifest's model deployment name, trimmed. Empty when unset. +func (m *promptAgentManifest) model() string { + if m == nil { + return "" + } + return strings.TrimSpace(m.definition.Model) +} + +// description returns the manifest's description, trimmed. Empty when unset. +func (m *promptAgentManifest) description() string { + if m == nil || m.definition.Description == nil { + return "" + } + return strings.TrimSpace(*m.definition.Description) +} + +// instructions returns the manifest's inline instructions. +func (m *promptAgentManifest) instructions() string { + if m == nil { + return "" + } + return strings.TrimSpace(m.definition.Instructions) +} + +// looksLikePromptAgentManifest reports whether the given YAML content is a +// prompt-agent manifest (`kind: prompt`) rather than a hosted/workflow agent +// manifest or a unified azure.yaml. +// +// It deliberately inspects only the top-level `kind` so a manifest that is +// otherwise malformed still routes to the prompt flow and fails there with a +// prompt-specific error, instead of being silently handed to the hosted flow. +func looksLikePromptAgentManifest(content []byte) bool { + var top map[string]any + if err := yaml.Unmarshal(content, &top); err != nil { + return false + } + kind, ok := top["kind"].(string) + if !ok { + return false + } + return strings.EqualFold(strings.TrimSpace(kind), string(agent_yaml.AgentKindPrompt)) +} + +// loadPromptAgentManifest parses prompt-agent manifest content into the seed +// runInitManaged scaffolds from. sourceDir is the directory the content came +// from and may be empty for a remote pointer. +func loadPromptAgentManifest(content []byte, sourceDir string) (*promptAgentManifest, error) { + var definition agent_yaml.PromptAgent + if err := yaml.Unmarshal(content, &definition); err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("manifest is not a valid prompt agent: %s", err), + "fix the manifest to match the prompt agent schema (kind: prompt, name, model)", + ) + } + if !strings.EqualFold(string(definition.Kind), string(agent_yaml.AgentKindPrompt)) { + return nil, exterrors.Validation( + exterrors.CodeUnsupportedAgentKind, + fmt.Sprintf("manifest declares kind %q, expected prompt", definition.Kind), + "use kind: prompt for prompt and managed agents", + ) + } + return &promptAgentManifest{definition: definition, sourceDir: sourceDir}, nil +} + +// loadPromptManifestFromPointer inspects `--manifest` (or the positional +// template pointer it was resolved into) and returns the parsed prompt-agent +// manifest when it declares `kind: prompt`. +// +// It returns (nil, nil) when no pointer was supplied, when the pointer cannot +// be read, or when the content is not a prompt-agent manifest — all of which +// mean "not my flow", leaving the hosted and unified-azure.yaml paths to handle +// it exactly as before. Only a pointer that is unambiguously a prompt agent but +// fails to parse surfaces an error. +func loadPromptManifestFromPointer( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, + httpClient *http.Client, +) (*promptAgentManifest, error) { + pointer := strings.TrimSpace(flags.manifestPointer) + if pointer == "" { + return nil, nil + } + + content, ok := readManifestContentForInitDetection(ctx, azdClient, pointer, httpClient) + if !ok || !looksLikePromptAgentManifest(content) { + return nil, nil + } + + // A sibling instructions.md is only reachable for a local pointer; for a + // remote one the manifest must carry its instructions inline. + sourceDir := "" + if isLocalFilePath(pointer) { + if abs, err := filepath.Abs(pointer); err == nil { + sourceDir = filepath.Dir(abs) + } + } + + return loadPromptAgentManifest(content, sourceDir) +} + +// resolveManifestInitHarness resolves the harness for a prompt-agent manifest +// adopted without an explicit --kind. An explicit --harness always wins; +// otherwise the manifest's own harness is honored, so a template that declares +// `harness: github-copilot` scaffolds a managed agent and one that declares none +// scaffolds a plain prompt agent. +func resolveManifestInitHarness(harnessFlag, manifestHarness string) (string, error) { + if strings.TrimSpace(harnessFlag) != "" { + return resolveInitHarness(harnessFlag, AgentKindChoicePrompt) + } + return resolveInitHarness(manifestHarness, AgentKindChoicePrompt) +} + // runInitManaged is the entry point for `azd ai agent init` when the user has -// selected the "prompt" (kind=managed) agent kind. It produces a first-class -// azd project so prompt agents follow the same `azd up` / `azd deploy` -// lifecycle as hosted agents: +// selected one of the prompt agent kinds. It produces a first-class azd project +// so prompt agents follow the same `azd up` / `azd deploy` lifecycle as hosted +// agents: // // 1. Scaffolds (or reuses) an azd project + infra via ensureProject — the // same azd-ai-starter-basic template the hosted flow uses. -// 2. Writes an agent.yaml (kind=managed) into the service directory. +// 2. Writes an agent.yaml (kind: prompt) into the service directory. // 3. Adds an azure.yaml service entry (Host=azure.ai.agent) whose config // carries the harness connection details in a promptAgent block. // -// The harness create/invoke/delete then happen through the service-target -// provider during `azd deploy` / `azd up`, exactly like hosted agents — no -// bespoke standalone deploy command or sidecar config file. +// The create/invoke/delete then happen through the service-target provider +// during `azd deploy` / `azd up`, exactly like hosted agents — no bespoke +// standalone deploy command or sidecar config file. +// +// harness selects the prompt agent flavor. An empty harness scaffolds a plain +// prompt agent that Foundry runs directly; a non-empty harness +// ("github-copilot") +// scaffolds a managed agent whose Brain+Hand sandbox the platform provisions. +// +// manifest, when non-nil, seeds the agent name, description, model, and +// instructions from a supplied template so `--manifest` works for both prompt +// flavors. Explicit flags always win over manifest values. func runInitManaged( ctx context.Context, flags *initFlags, azdClient *azdext.AzdClient, + harness string, + manifest *promptAgentManifest, ) error { + // Fail before anything is written when non-interactive mode is missing an + // input that has no deterministic fallback. ensureProject below creates a + // project folder and azd environment, so a late failure would strand a + // half-scaffolded project with no services: entry. + if err := validateManagedNoPromptInputs(flags, manifest); err != nil { + return err + } + // Prompt for the conceptual agent details first: name and description. - agentName, err := promptManagedAgentName(ctx, azdClient, flags) + agentName, err := promptManagedAgentName(ctx, azdClient, flags, manifest) if err != nil { return err } - description, err := promptManagedAgentDescription(ctx, azdClient, flags) + description, err := promptManagedAgentDescription(ctx, azdClient, flags, manifest) if err != nil { return err } + // Treat a manifest's model as if it had been passed as --model so the whole + // downstream resolution (catalog lookup, region availability, quota, SKU) + // targets the template's model rather than the generic default. + if strings.TrimSpace(flags.model) == "" && strings.TrimSpace(flags.modelDeployment) == "" { + flags.model = manifest.model() + } + // The harness base URL is where the agent runtime lives (env-overridable). // Independently of that, the prompt-agent init experience mirrors hosted: - // in interactive mode we always walk subscription -> Foundry project -> - // model so the workspace tuple and model endpoint come from a real project. - // --no-prompt skips the interactive Azure resolution and uses flags/env. + // we always walk subscription -> Foundry project -> model so the workspace + // tuple and model endpoint come from a real project. In --no-prompt the + // same walk runs unattended, resolving each step from flags and the azd + // environment (AZURE_SUBSCRIPTION_ID, AZURE_LOCATION, --project-id, + // --model-deployment, --model) instead of prompting. settings := project.DefaultPromptAgentSettings() if envBaseURL := strings.TrimSpace(os.Getenv(project.PromptBaseURLEnvVar)); envBaseURL != "" { settings.BaseURL = envBaseURL } - useGuidedFoundry := !flags.noPrompt // Decide where the project lives and where the agent.yaml goes within it. // When an azd project already exists in the cwd we add the agent as a new @@ -86,6 +256,14 @@ func runInitManaged( serviceRelPath = "." } + // Resolve the instructions before ensureProject changes the working + // directory: a manifest-supplied instructions.md is read relative to the + // manifest, which may be a path relative to the original cwd. + instructions, err := promptManagedAgentInstructions(ctx, azdClient, flags, manifest) + if err != nil { + return err + } + // Scaffold or locate the azd project + infra. On a fresh scaffold this // downloads the starter template and chdirs into the new project folder. if _, err := ensureProject(ctx, flags, azdClient, projectTargetDir); err != nil { @@ -104,33 +282,25 @@ func runInitManaged( // Resolve the model deployment. The guided path walks subscription -> // Foundry project -> model (version/SKU/capacity/name) and returns a full - // deployment to provision and reference; otherwise we use the curated/custom - // model prompt (or --model in --no-prompt mode). - var ( - model string - deployment *project.Deployment - ) - if useGuidedFoundry { - deployment, err = resolvePromptHarnessTarget(ctx, azdClient, flags, env, &settings) - if err != nil { - return err - } - if deployment != nil { - model = deployment.Name - } + // deployment to provision and reference. It runs in both interactive and + // non-interactive mode so the harness target is always configured; without + // it a --no-prompt scaffold would carry only placeholder routing values and + // `azd up` would fail to find a Foundry project. + var model string + deployment, foundryProject, err := resolvePromptHarnessTarget(ctx, azdClient, flags, env, &settings) + if err != nil { + return err + } + if deployment != nil { + model = deployment.Name } if strings.TrimSpace(model) == "" { - model, err = promptManagedAgentModel(ctx, azdClient, flags) + model, err = promptManagedAgentModel(ctx, azdClient, flags, manifest) if err != nil { return err } } - instructions, err := promptManagedAgentInstructions(ctx, azdClient, flags) - if err != nil { - return err - } - // cwd is now the project root. Create the service directory when nested. if serviceRelPath != "." { if err := os.MkdirAll(serviceRelPath, osutil.PermissionDirectory); err != nil { @@ -144,9 +314,25 @@ func runInitManaged( Kind: agent_yaml.AgentKindPrompt, }, Model: model, - // Instructions are written to a sibling instructions.md by the - // convention scaffolding below, so they are omitted inline here. The - // deploy engine reads instructions.md when no inline value is present. + // An empty harness is omitted from agent.yaml entirely, which is what + // distinguishes a plain prompt agent from a managed (harnessed) one. + Harness: harness, + // Instructions are inline, matching the prompt-agent API schema. + Instructions: promptScaffoldInstructions(instructions), + } + // Carry the authored parts of a supplied manifest through to the scaffold. + // Tools, skills, connections, and the toolbox reference are the reason a + // user supplies a template at all; dropping them would silently produce a + // bare agent that does not match the template they asked for. + if manifest != nil { + promptAgent.Skills = manifest.definition.Skills + promptAgent.Tools = manifest.definition.Tools + promptAgent.ToolChoice = manifest.definition.ToolChoice + promptAgent.StructuredInputs = manifest.definition.StructuredInputs + promptAgent.Policies = manifest.definition.Policies + promptAgent.Connections = manifest.definition.Connections + promptAgent.Toolbox = manifest.definition.Toolbox + promptAgent.Memory = manifest.definition.Memory } if strings.TrimSpace(description) != "" { desc := strings.TrimSpace(description) @@ -156,14 +342,34 @@ func runInitManaged( return err } - // Scaffold the convention-based authoring layout (instructions.md + an - // empty skills/ folder) so the deploy engine's folder conventions are + // Scaffold the convention-based authoring layout (empty skills/ and + // vector-assets/ folders) so the deploy engine's folder conventions are // discoverable from a fresh init. - if err := scaffoldPromptConventionFolders(serviceRelPath, instructions); err != nil { + if err := scaffoldPromptConventionFolders(serviceRelPath); err != nil { return err } - if err := addPromptAgentService(ctx, azdClient, agentName, serviceRelPath, &settings, deployment); err != nil { + if err := addPromptAgentService(ctx, azdClient, agentName, serviceRelPath); err != nil { + return err + } + + // Model deployments live on a sibling azure.ai.project service, not on the + // agent service, so a prompt agent's azure.yaml has the same shape as a + // hosted agent's. emitResourceServices also wires the agent's uses: list so + // `azd provision` creates the project (and its deployments) first. + var deployments []project.Deployment + if deployment != nil { + deployments = []project.Deployment{*deployment} + } + endpointRef, err := recordFoundryProjectEnv(ctx, azdClient, env.Name, foundryProject) + if err != nil { + return err + } + if err := emitResourceServices( + ctx, azdClient, agentName, + endpointRef, + deployments, nil, nil, + ); err != nil { return err } @@ -175,39 +381,64 @@ func runInitManaged( } } - printManagedInitSummary(agentName, model, serviceRelPath, projectTargetDir, existingProject, &settings) + printManagedInitSummary(agentName, model, harness, serviceRelPath, projectTargetDir, existingProject, &settings) return nil } // addPromptAgentService registers the prompt agent as an azure.yaml service // entry with Host=azure.ai.agent and a promptAgent config block. Unlike hosted -// agents there is no Docker/Language — the harness owns the runtime. When a -// resolved model deployment is supplied it is recorded under the service config -// so `azd provision` creates it (via AI_PROJECT_DEPLOYMENTS), mirroring hosted. +// agents there is no Docker/Language — the harness owns the runtime. +// +// Model deployments are deliberately NOT recorded here: they belong to the +// sibling azure.ai.project service that emitPromptResourceServices writes, the +// same shape hosted agents use. +// addPromptAgentService registers the prompt agent as an azure.yaml service +// entry with Host=azure.ai.agent. Unlike hosted agents there is no +// Docker/Language -- the harness owns the runtime. +// +// The config: block carries a promptAgent entry whose every field is a ${VAR} +// reference (see promptAgentEnvRefs). Its presence is the structural marker +// that distinguishes a prompt agent from a hosted one -- `azd ai agent init` +// writes no explicit kind: into the service config, and both the deploy provider +// and the provisioning synthesizer key off this block. Writing references rather +// than literals keeps the shape of the configuration visible while leaving the +// tenant-specific values in the azd environment, so the project can be copied to +// another subscription and deployed unchanged. +// +// Model deployments are deliberately NOT recorded here: they belong to the +// sibling azure.ai.project service that emitResourceServices writes, the +// same shape hosted agents use. func addPromptAgentService( ctx context.Context, azdClient *azdext.AzdClient, agentName, serviceRelPath string, - settings *project.PromptAgentSettings, - deployment *project.Deployment, ) error { agentConfig := project.ServiceTargetAgentConfig{ - PromptAgent: settings, - } - if deployment != nil { - agentConfig.Deployments = []project.Deployment{*deployment} + PromptAgent: promptAgentEnvRefs(), } configStruct, err := project.MarshalStruct(&agentConfig) if err != nil { return fmt.Errorf("marshaling prompt agent service config: %w", err) } + // Name the manifest explicitly on the service entry. Deploy would find + // agent.yaml by convention anyway, but writing it makes the service -> manifest + // edge readable in azure.yaml and gives the developer one line to edit when + // they want a different filename. + serviceProps, err := structpb.NewStruct(map[string]any{ + project.AgentManifestServiceKey: promptAgentManifestFileName, + }) + if err != nil { + return fmt.Errorf("marshaling prompt agent service properties: %w", err) + } + req := &azdext.AddServiceRequest{ Service: &azdext.ServiceConfig{ - Name: agentName, - RelativePath: serviceRelPath, - Host: AiAgentHost, - Config: configStruct, + Name: agentName, + RelativePath: serviceRelPath, + Host: AiAgentHost, + Config: configStruct, + AdditionalProperties: serviceProps, }, } if _, err := azdClient.Project().AddService(ctx, req); err != nil { @@ -216,30 +447,72 @@ func addPromptAgentService( return nil } +// validateManagedNoPromptInputs rejects a non-interactive invocation that is +// missing an input with no deterministic fallback, before runInitManaged writes +// anything to disk. +// +// The individual prompt helpers below also guard on flags.noPrompt, but they run +// at different points in the flow — the model resolution in particular happens +// after ensureProject has already created a project folder and azd environment. +// Checking everything up front keeps a failed --no-prompt init from leaving a +// partially scaffolded project behind. +func validateManagedNoPromptInputs(flags *initFlags, manifest *promptAgentManifest) error { + if !flags.noPrompt { + return nil + } + if strings.TrimSpace(flags.agentName) == "" && manifest.agentName() == "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-name is required in non-interactive mode for prompt agents", + "pass --agent-name , or supply a manifest with --manifest that declares name:", + ) + } + if strings.TrimSpace(flags.model) == "" && + strings.TrimSpace(flags.modelDeployment) == "" && + manifest.model() == "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--model or --model-deployment is required in non-interactive mode for prompt agents", + "pass --model to deploy a new model, --model-deployment to reuse an "+ + "existing deployment, or supply a manifest with --manifest that declares model:", + ) + } + return nil +} + // promptManagedAgentName asks for the agent's name. The name is the Foundry // agent identity and (for a fresh project) the project folder name. It matches // the hosted flow's message, help text, and validation so the two flows feel -// the same. +// the same. A manifest-supplied name seeds the interactive default and is used +// outright when --agent-name is absent in non-interactive mode. func promptManagedAgentName( ctx context.Context, azdClient *azdext.AzdClient, flags *initFlags, + manifest *promptAgentManifest, ) (string, error) { if strings.TrimSpace(flags.agentName) != "" { return validateInitAgentName(flags.agentName) } + defaultName := manifest.agentName() if flags.noPrompt { + if defaultName != "" { + return validateInitAgentName(defaultName) + } return "", exterrors.Validation( exterrors.CodeInvalidParameter, "--agent-name is required in non-interactive mode for prompt agents", - "pass --agent-name on the command line", + "pass --agent-name , or supply a manifest with --manifest that declares name:", ) } + if defaultName == "" { + defaultName = "my-prompt-agent" + } resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ Options: &azdext.PromptOptions{ Message: "Enter a name for your agent", - DefaultValue: "my-prompt-agent", + DefaultValue: defaultName, HelpMessage: "Foundry agents are unique by name within a project. " + "Reusing a name creates a new version of the existing agent.", }, @@ -252,30 +525,32 @@ func promptManagedAgentName( } name := strings.TrimSpace(resp.Value) if name == "" { - name = "my-prompt-agent" + name = defaultName } return validateInitAgentName(name) } // promptManagedAgentDescription asks for an optional human-readable // description, mirroring the hosted flow. Blank is allowed. In --no-prompt -// mode the --description flag value (or empty) is used. +// mode the --description flag value (or the manifest's, or empty) is used. func promptManagedAgentDescription( ctx context.Context, azdClient *azdext.AzdClient, flags *initFlags, + manifest *promptAgentManifest, ) (string, error) { if strings.TrimSpace(flags.description) != "" { return strings.TrimSpace(flags.description), nil } + defaultDescription := manifest.description() if flags.noPrompt { - return "", nil + return defaultDescription, nil } resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ Options: &azdext.PromptOptions{ Message: "Enter a description for your agent (optional)", - DefaultValue: "", + DefaultValue: defaultDescription, Required: false, IgnoreHintKeys: true, HelpMessage: "A short summary of what this agent does. Written to agent.yaml and shown in Foundry.", @@ -305,20 +580,32 @@ var promptManagedAgentModelChoices = []string{ // promptManagedAgentModel asks which model deployment the agent should call. // Unlike a bare text field, it offers a curated list of common models plus a // "custom" escape hatch — a guided experience closer to the hosted model -// selection. The --model flag (or --no-prompt) bypasses the prompt. +// selection. --model-deployment, --model, a manifest model, or --no-prompt all +// bypass the prompt. +// +// This is only reached when the guided Foundry resolution did not produce a +// deployment (for example when the target project could not be resolved), so it +// records the model name without provisioning anything. func promptManagedAgentModel( ctx context.Context, azdClient *azdext.AzdClient, flags *initFlags, + manifest *promptAgentManifest, ) (string, error) { + if strings.TrimSpace(flags.modelDeployment) != "" { + return strings.TrimSpace(flags.modelDeployment), nil + } if strings.TrimSpace(flags.model) != "" { return strings.TrimSpace(flags.model), nil } + if manifestModel := manifest.model(); manifestModel != "" { + return manifestModel, nil + } if flags.noPrompt { return "", exterrors.Validation( exterrors.CodeInvalidParameter, - "--model is required in non-interactive mode for prompt agents", - "pass --model on the command line", + "--model or --model-deployment is required in non-interactive mode for prompt agents", + "pass --model or --model-deployment on the command line", ) } @@ -376,12 +663,19 @@ func promptManagedAgentModel( } // promptManagedAgentInstructions asks for the agent's system instructions. -// In no-prompt mode it returns a stub the user can edit later. +// A manifest's instructions (inline, or a sibling instructions.md) are used +// verbatim — a template author already wrote them, so re-prompting would only +// invite the user to overwrite them by accident. Otherwise, in no-prompt mode +// it returns a stub the user can edit later. func promptManagedAgentInstructions( ctx context.Context, azdClient *azdext.AzdClient, flags *initFlags, + manifest *promptAgentManifest, ) (string, error) { + if manifestInstructions := manifest.instructions(); manifestInstructions != "" { + return manifestInstructions, nil + } if flags.noPrompt { return "You are a helpful AI assistant. Replace these instructions before deploying.", nil } @@ -426,7 +720,7 @@ func writePromptAgentYAML(targetDir string, promptAgent *agent_yaml.PromptAgent) return fmt.Errorf("preparing agent.yaml file contents: %w", err) } - filePath := filepath.Join(targetDir, "agent.yaml") + filePath := filepath.Join(targetDir, promptAgentManifestFileName) if err := os.WriteFile(filePath, buf.Bytes(), osutil.PermissionFile); err != nil { return fmt.Errorf("saving file to %s: %w", filePath, err) } @@ -434,32 +728,31 @@ func writePromptAgentYAML(targetDir string, promptAgent *agent_yaml.PromptAgent) return nil } +// promptScaffoldInstructions returns the instructions to write inline into a +// scaffolded agent.yaml, falling back to a neutral default so a freshly +// initialized agent is deployable without editing. +func promptScaffoldInstructions(instructions string) string { + if trimmed := strings.TrimSpace(instructions); trimmed != "" { + return trimmed + } + return "You are a helpful AI assistant." +} + // scaffoldPromptConventionFolders writes the convention-based authoring layout // next to agent.yaml so the deploy engine's folder conventions are discoverable // from a fresh init: // -// - instructions.md — the agent's instructions (deploy uses this when the -// agent.yaml has no inline instructions). -// - skills/ — add one subfolder per skill (each with a SKILL.md). +// - skills/ — add one subfolder per skill (each with a SKILL.md). +// - vector-assets/ — drop documents here to ground the agent; deploy uploads +// them to a vector store and attaches a file_search tool. // // The empty folders are kept with a .gitkeep placeholder. The deploy scanners -// ignore dotfiles, so .gitkeep never contributes content. An existing -// instructions.md is never overwritten so re-running init preserves edits. -func scaffoldPromptConventionFolders(targetDir, instructions string) error { - if strings.TrimSpace(instructions) == "" { - instructions = "You are a helpful AI assistant." - } - - instructionsPath := filepath.Join(targetDir, "instructions.md") - if !fileExists(instructionsPath) { - content := strings.TrimRight(instructions, "\n") + "\n" - if err := os.WriteFile(instructionsPath, []byte(content), osutil.PermissionFile); err != nil { - return fmt.Errorf("writing instructions.md: %w", err) - } - log.Printf("Wrote instructions.md at %s", instructionsPath) - } - - for _, sub := range []string{"skills"} { +// ignore dotfiles, so .gitkeep never contributes content. +// +// Instructions are not scaffolded here: they are written inline into +// agent.yaml, matching the prompt-agent API schema. +func scaffoldPromptConventionFolders(targetDir string) error { + for _, sub := range []string{"skills", "vector-assets"} { dir := filepath.Join(targetDir, sub) if err := os.MkdirAll(dir, osutil.PermissionDirectory); err != nil { return fmt.Errorf("creating %s folder: %w", sub, err) @@ -476,7 +769,7 @@ func scaffoldPromptConventionFolders(targetDir, instructions string) error { // printManagedInitSummary prints a concise summary plus next-step hint. func printManagedInitSummary( - agentName, model, serviceRelPath, projectTargetDir string, + agentName, model, harness, serviceRelPath, projectTargetDir string, existingProject bool, settings *project.PromptAgentSettings, ) { @@ -489,6 +782,9 @@ func printManagedInitSummary( fmt.Printf(" Agent file: %s\n", agentFile) fmt.Printf(" Model: %s\n", model) fmt.Printf(" Service entry: added to azure.yaml (host: %s)\n", AiAgentHost) + if harness != "" { + fmt.Printf(" Harness: %s\n", harness) + } fmt.Printf(" Harness URL: %s\n", settings.BaseURL) // Surface the resolved Foundry target when it isn't the local-dev default // (i.e. the guided subscription -> project -> model path ran). @@ -506,8 +802,9 @@ func printManagedInitSummary( } fmt.Println() fmt.Println("Authoring layout (edit these to add capabilities):") - fmt.Printf(" %sinstructions.md the agent's instructions\n", dirPrefix) - fmt.Printf(" %sskills/ add a subfolder per skill (each with a SKILL.md)\n", dirPrefix) + fmt.Printf(" %s%-16s the agent's instructions\n", dirPrefix, "agent.yaml") + fmt.Printf(" %s%-16s add a subfolder per skill (each with a SKILL.md)\n", dirPrefix, "skills/") + fmt.Printf(" %s%-16s drop documents here to ground the agent\n", dirPrefix, "vector-assets/") fmt.Println() fmt.Println("Next steps:") diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go index ac82880293e..7561ea69417 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go @@ -22,21 +22,53 @@ import ( // (select existing or create new) -> model deployment (version, SKU, capacity, // name). It populates the harness workspace tuple and model endpoint on // settings from the selected/created project, and returns the resolved model -// deployment to persist to azure.yaml. +// deployment to persist to azure.yaml along with the selected existing project +// (nil when a new one will be provisioned), which the caller needs to name and +// mark the sibling azure.ai.project service. // // Location is NOT prompted separately: for an existing project it is derived // from the project; for a new project it is prompted only at that point — the // same architecture hosted agents rely on. +// +// The same walk runs under --no-prompt, resolving each step deterministically: +// the subscription and location come from the azd environment +// (AZURE_SUBSCRIPTION_ID / AZURE_LOCATION), the project from --project-id (or, +// when absent, the create-new path), and the deployment from +// --model-deployment / --model. func resolvePromptHarnessTarget( ctx context.Context, azdClient *azdext.AzdClient, flags *initFlags, env *azdext.Environment, settings *project.PromptAgentSettings, -) (*project.Deployment, error) { +) (*project.Deployment, *FoundryProjectInfo, error) { azureContext, err := loadAzureContext(ctx, azdClient, env.Name) if err != nil { - return nil, err + return nil, nil, err + } + + // A full project resource ID already names its subscription, so seed the + // context from it. Without this, `--no-prompt --project-id ` against a + // fresh environment would fail asking for AZURE_SUBSCRIPTION_ID even though + // the caller just supplied it. + if strings.TrimSpace(flags.projectResourceId) != "" && azureContext.Scope.SubscriptionId == "" { + if proj, parseErr := extractProjectDetails(flags.projectResourceId); parseErr == nil { + azureContext.Scope.SubscriptionId = proj.SubscriptionId + } + } + + // A non-interactive caller may have neither a project nor an Azure context + // yet (a fresh environment in CI). Rather than aborting after the project + // scaffold has already been written, mirror the hosted flow: finish the + // scaffold, warn, and print exactly which values to set before + // `azd provision`. The agent's model still comes from --model / + // --model-deployment / the manifest, so agent.yaml is complete. + if strings.TrimSpace(flags.projectResourceId) == "" && + shouldDeferInitAzureContext(flags.noPrompt, azureContext) { + if err := configureDeferredInitAzureContext(ctx, azdClient, env.Name, azureContext, true); err != nil { + return nil, nil, err + } + return nil, nil, nil } // Subscription only — location is resolved per project branch below. @@ -45,14 +77,14 @@ func resolvePromptHarnessTarget( "Select an Azure subscription to find your Foundry project and models.", ) if err != nil { - return nil, err + return nil, nil, err } proj, err := selectPromptFoundryProject( - ctx, azdClient, cred, azureContext, env.Name, flags.projectResourceId, + ctx, azdClient, cred, azureContext, env.Name, flags.projectResourceId, flags.noPrompt, ) if err != nil { - return nil, err + return nil, nil, err } if proj == nil { @@ -63,17 +95,18 @@ func resolvePromptHarnessTarget( "with the model deployment you choose next.", )) if err := ensureLocation(ctx, azdClient, azureContext, env.Name); err != nil { - return nil, err + return nil, nil, err } if err := setEnvValue(ctx, azdClient, env.Name, "USE_EXISTING_AI_PROJECT", "false"); err != nil { - return nil, err + return nil, nil, err } if err := updatePendingProjectSignal(ctx, azdClient, env.Name, false); err != nil { log.Printf("warning: failed to update project provision signal: %v", err) } // A new project is provisioned by `azd up`; the harness workspace tuple // is filled from the provisioned env values at deploy time (overlay). - return resolvePromptModelDeployment(ctx, azdClient, azureContext, env, flags) + deployment, err := resolvePromptModelDeployment(ctx, azdClient, azureContext, env, flags) + return deployment, nil, err } // Existing project: populate the harness target and derive the location @@ -92,7 +125,7 @@ func resolvePromptHarnessTarget( azureContext.Scope.Location = proj.Location if proj.Location != "" { if err := setEnvValue(ctx, azdClient, env.Name, "AZURE_AI_DEPLOYMENTS_LOCATION", proj.Location); err != nil { - return nil, err + return nil, nil, err } // Also seed AZURE_LOCATION from the selected project's region. The // infra main.parameters.json resolves `location` from ${AZURE_LOCATION}; @@ -100,21 +133,22 @@ func resolvePromptHarnessTarget( // (and thus the target region) is already known. Deploy the model using // the project's region. if err := setEnvValue(ctx, azdClient, env.Name, "AZURE_LOCATION", proj.Location); err != nil { - return nil, err + return nil, nil, err } } if err := setPromptFoundryProjectEnv(ctx, azdClient, env.Name, proj); err != nil { - return nil, err + return nil, nil, err } if err := setEnvValue(ctx, azdClient, env.Name, "USE_EXISTING_AI_PROJECT", "true"); err != nil { - return nil, err + return nil, nil, err } if err := updatePendingProjectSignal(ctx, azdClient, env.Name, true); err != nil { log.Printf("warning: failed to update project provision signal: %v", err) } - return resolvePromptModelForExistingProject(ctx, azdClient, cred, azureContext, env, flags, proj) + deployment, err := resolvePromptModelForExistingProject(ctx, azdClient, cred, azureContext, env, flags, proj) + return deployment, proj, err } // selectPromptFoundryProject lists the Foundry projects in the subscription and @@ -124,6 +158,12 @@ func resolvePromptHarnessTarget( // // Unlike the hosted selectFoundryProject this does NOT filter by region or // configure ACR/AppInsights connections, which are irrelevant to prompt agents. +// +// In non-interactive mode without --project-id there is no basis for picking +// one of the subscription's existing projects, so it returns nil to take the +// create-new path. That is the only deterministic choice: `azd up` then +// provisions a project dedicated to this agent rather than silently adopting an +// arbitrary pre-existing one. func selectPromptFoundryProject( ctx context.Context, azdClient *azdext.AzdClient, @@ -131,11 +171,15 @@ func selectPromptFoundryProject( azureContext *azdext.AzureContext, envName string, projectResourceId string, + noPrompt bool, ) (*FoundryProjectInfo, error) { subscriptionId := azureContext.Scope.SubscriptionId if strings.TrimSpace(projectResourceId) != "" { return getFoundryProject(ctx, credential, subscriptionId, projectResourceId) } + if noPrompt { + return nil, nil + } projects, err := listFoundryProjects(ctx, credential, subscriptionId) if err != nil { @@ -236,6 +280,14 @@ func resolvePromptModelForExistingProject( flags *initFlags, proj *FoundryProjectInfo, ) (*project.Deployment, error) { + // --model-deployment names an existing deployment in this project to reuse + // verbatim, which is the non-interactive equivalent of picking one from the + // list below. It wins over --model so `--model-deployment x --model y` does + // not silently provision a second deployment. + if requested := strings.TrimSpace(flags.modelDeployment); requested != "" { + return findExistingPromptDeployment(ctx, credential, proj, requested) + } + // --model short-circuits to the new-deployment configuration so the named // model is resolved (version/SKU/capacity) and provisioned. if strings.TrimSpace(flags.model) == "" { @@ -283,19 +335,7 @@ func resolvePromptModelForExistingProject( return nil, fmt.Errorf("prompting for model deployment: %w", selErr) } if selected := choices[*resp.Value].Value; selected != newModelValue { - d := byName[selected] - return &project.Deployment{ - Name: d.Name, - Model: project.DeploymentModel{ - Name: d.ModelName, - Format: d.ModelFormat, - Version: d.Version, - }, - Sku: project.DeploymentSku{ - Name: d.SkuName, - Capacity: d.SkuCapacity, - }, - }, nil + return promptDeploymentFromFoundry(byName[selected]), nil } } } @@ -303,6 +343,62 @@ func resolvePromptModelForExistingProject( return resolvePromptModelDeployment(ctx, azdClient, azureContext, env, flags) } +// findExistingPromptDeployment resolves a named model deployment in the given +// Foundry project so `--model-deployment` can reference a live deployment +// without provisioning a new one. A missing deployment is an error rather than +// a fallback to the catalog: silently deploying a different model than the one +// the caller named would surprise them and cost them quota. +func findExistingPromptDeployment( + ctx context.Context, + credential azcore.TokenCredential, + proj *FoundryProjectInfo, + deploymentName string, +) (*project.Deployment, error) { + deployments, err := listProjectDeployments( + ctx, credential, proj.SubscriptionId, proj.ResourceGroupName, proj.AccountName, + ) + if err != nil { + return nil, fmt.Errorf("listing model deployments for Foundry project %q: %w", proj.ProjectName, err) + } + + available := make([]string, 0, len(deployments)) + for i := range deployments { + if strings.EqualFold(deployments[i].Name, deploymentName) { + return promptDeploymentFromFoundry(&deployments[i]), nil + } + available = append(available, deployments[i].Name) + } + + suggestion := "create the deployment first, or pass --model to have `azd up` deploy it" + if len(available) > 0 { + suggestion = fmt.Sprintf("deployments in this project: %s. %s", strings.Join(available, ", "), suggestion) + } + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf( + "model deployment %q was not found in Foundry project %q", deploymentName, proj.ProjectName, + ), + suggestion, + ) +} + +// promptDeploymentFromFoundry converts a discovered Foundry model deployment +// into the azure.yaml deployment entry recorded on the prompt agent service. +func promptDeploymentFromFoundry(d *FoundryDeploymentInfo) *project.Deployment { + return &project.Deployment{ + Name: d.Name, + Model: project.DeploymentModel{ + Name: d.ModelName, + Format: d.ModelFormat, + Version: d.Version, + }, + Sku: project.DeploymentSku{ + Name: d.SkuName, + Capacity: d.SkuCapacity, + }, + } +} + // resolvePromptModelDeployment runs the full "deploy a new model" flow — model // selection from the catalog, then version / SKU / capacity via the shared // modelSelector, then a deployment-name prompt — and returns the resulting @@ -336,8 +432,13 @@ func resolvePromptModelDeployment( } // Deployment name (defaults to the model name), matching hosted. + // --model-deployment names the deployment explicitly, which is how a + // non-interactive caller controls it on the create-new-project path where + // there is no existing deployment to look up. deploymentName := modelDetails.ModelName - if !flags.noPrompt { + if requested := strings.TrimSpace(flags.modelDeployment); requested != "" { + deploymentName = requested + } else if !flags.noPrompt { resp, promptErr := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ Options: &azdext.PromptOptions{ Message: fmt.Sprintf( diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go new file mode 100644 index 00000000000..38aa8f53cb1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" +) + +func TestLooksLikePromptAgentManifest(t *testing.T) { + tests := []struct { + name string + content string + want bool + }{ + { + name: "prompt agent", + content: "kind: prompt\nname: my-agent\nmodel: gpt-4.1-mini\n", + want: true, + }, + { + name: "prompt agent with harness", + content: "kind: prompt\nname: my-agent\nmodel: gpt-4.1-mini\nharness: github-copilot\n", + want: true, + }, + { + name: "kind casing is ignored", + content: "kind: Prompt\nname: my-agent\n", + want: true, + }, + { + name: "hosted container agent", + content: "kind: container\nname: my-agent\nprotocols: []\n", + want: false, + }, + { + name: "unified azure.yaml", + content: "name: my-project\nservices:\n agent:\n host: azure.ai.agent\n", + want: false, + }, + { + name: "not yaml", + content: "\t\tnot: [valid", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := looksLikePromptAgentManifest([]byte(tt.content)); got != tt.want { + t.Errorf("looksLikePromptAgentManifest = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLoadPromptAgentManifest(t *testing.T) { + content := []byte( + "kind: prompt\n" + + "name: triage-agent\n" + + "description: Triages incoming issues\n" + + "model: gpt-4.1\n" + + "harness: github-copilot\n" + + "instructions: You triage issues.\n" + + "skills:\n - summarize\n" + + "tools:\n - type: code_interpreter\n", + ) + + manifest, err := loadPromptAgentManifest(content, "") + if err != nil { + t.Fatalf("loadPromptAgentManifest: %v", err) + } + if got := manifest.agentName(); got != "triage-agent" { + t.Errorf("agentName = %q", got) + } + if got := manifest.model(); got != "gpt-4.1" { + t.Errorf("model = %q", got) + } + if got := manifest.description(); got != "Triages incoming issues" { + t.Errorf("description = %q", got) + } + if got := manifest.instructions(); got != "You triage issues." { + t.Errorf("instructions = %q", got) + } + if got := manifest.definition.Harness; got != agent_api.ManagedAgentHarnessGitHubCopilot { + t.Errorf("harness = %q", got) + } + if len(manifest.definition.Skills) != 1 || len(manifest.definition.Tools) != 1 { + t.Errorf("skills/tools were not carried through: %+v", manifest.definition) + } +} + +func TestLoadPromptAgentManifest_RejectsNonPromptKind(t *testing.T) { + if _, err := loadPromptAgentManifest([]byte("kind: container\nname: a\n"), ""); err == nil { + t.Fatal("expected an error for a non-prompt manifest kind") + } +} + +// Instructions are declared inline in the manifest, so the scaffold carries +// the authored prose through rather than the generic placeholder. +func TestPromptAgentManifest_InlineInstructions(t *testing.T) { + authored := "You are a release notes summarizer." + + manifest, err := loadPromptAgentManifest( + []byte("kind: prompt\nname: a\nmodel: gpt-4.1-mini\ninstructions: "+authored+"\n"), t.TempDir(), + ) + if err != nil { + t.Fatalf("loadPromptAgentManifest: %v", err) + } + if got := manifest.instructions(); got != authored { + t.Errorf("instructions = %q, want %q", got, authored) + } +} + +// A nil manifest is the common case (no --manifest), so every accessor must be +// nil-safe rather than forcing the caller to branch. +func TestPromptAgentManifest_NilAccessors(t *testing.T) { + var manifest *promptAgentManifest + if manifest.agentName() != "" || manifest.model() != "" || + manifest.description() != "" || manifest.instructions() != "" { + t.Error("nil manifest accessors should return empty strings") + } +} + +func TestResolveManifestInitHarness(t *testing.T) { + tests := []struct { + name string + harnessFlag string + manifestHarness string + want string + wantErr bool + }{ + {name: "manifest harness is honored", manifestHarness: "github-copilot", want: "github-copilot"}, + {name: "no harness anywhere means plain prompt agent"}, + {name: "harness flag wins", harnessFlag: "github-copilot", manifestHarness: "", want: "github-copilot"}, + {name: "harness none overrides manifest", harnessFlag: "none", manifestHarness: "github-copilot", want: ""}, + {name: "unknown flag value", harnessFlag: "bogus", wantErr: true}, + {name: "unknown manifest value", manifestHarness: "bogus", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveManifestInitHarness(tt.harnessFlag, tt.manifestHarness) + if tt.wantErr { + if err == nil { + t.Fatal("expected an error") + } + return + } + if err != nil { + t.Fatalf("resolveManifestInitHarness: %v", err) + } + if got != tt.want { + t.Errorf("harness = %q, want %q", got, tt.want) + } + }) + } +} + +// The non-interactive guard runs before ensureProject writes anything, so these +// cases are what keeps a failed `--no-prompt` init from stranding a +// half-scaffolded project on disk. +func TestValidateManagedNoPromptInputs(t *testing.T) { + promptManifest := func(name, model string) *promptAgentManifest { + return &promptAgentManifest{ + definition: agent_yaml.PromptAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindPrompt, + Name: name, + }, + Model: model, + }, + } + } + + tests := []struct { + name string + flags initFlags + manifest *promptAgentManifest + wantErr bool + }{ + { + name: "interactive needs nothing up front", + flags: initFlags{}, + }, + { + name: "no-prompt without name or model", + flags: initFlags{noPrompt: true}, + wantErr: true, + }, + { + name: "no-prompt with name but no model", + flags: initFlags{noPrompt: true, agentName: "a"}, + wantErr: true, + }, + { + name: "no-prompt with name and model", + flags: initFlags{noPrompt: true, agentName: "a", model: "gpt-4.1-mini"}, + }, + { + name: "no-prompt with name and model deployment", + flags: initFlags{noPrompt: true, agentName: "a", modelDeployment: "my-deployment"}, + }, + { + name: "no-prompt satisfied entirely by the manifest", + flags: initFlags{noPrompt: true}, + manifest: promptManifest("a", "gpt-4.1-mini"), + }, + { + name: "no-prompt with a manifest missing a model", + flags: initFlags{noPrompt: true}, + manifest: promptManifest("a", ""), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateManagedNoPromptInputs(&tt.flags, tt.manifest) + if tt.wantErr && err == nil { + t.Fatal("expected a validation error") + } + if !tt.wantErr && err != nil { + t.Fatalf("validateManagedNoPromptInputs: %v", err) + } + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go index 64b4828fbce..800ed75d5b8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go @@ -12,21 +12,12 @@ import ( func TestScaffoldPromptConventionFolders_CreatesLayout(t *testing.T) { dir := t.TempDir() - if err := scaffoldPromptConventionFolders(dir, "You are a triage assistant."); err != nil { + if err := scaffoldPromptConventionFolders(dir); err != nil { t.Fatalf("scaffoldPromptConventionFolders: %v", err) } - // instructions.md carries the provided instructions. - content, err := os.ReadFile(filepath.Join(dir, "instructions.md")) - if err != nil { - t.Fatalf("read instructions.md: %v", err) - } - if string(content) != "You are a triage assistant.\n" { - t.Errorf("instructions.md content: got %q", string(content)) - } - - // skills/ exists with a .gitkeep placeholder. - for _, sub := range []string{"skills"} { + // skills/ and vector-assets/ exist with a .gitkeep placeholder. + for _, sub := range []string{"skills", "vector-assets"} { info, statErr := os.Stat(filepath.Join(dir, sub)) if statErr != nil || !info.IsDir() { t.Errorf("%s/ should be a directory: %v", sub, statErr) @@ -35,44 +26,15 @@ func TestScaffoldPromptConventionFolders_CreatesLayout(t *testing.T) { t.Errorf("%s/.gitkeep should exist: %v", sub, keepErr) } } - - // files/ is intentionally not scaffolded: file search is not supported for - // managed (prompt) agents. - if _, statErr := os.Stat(filepath.Join(dir, "files")); !os.IsNotExist(statErr) { - t.Errorf("files/ should not be created, got stat err: %v", statErr) - } -} - -func TestScaffoldPromptConventionFolders_DefaultInstructions(t *testing.T) { - dir := t.TempDir() - if err := scaffoldPromptConventionFolders(dir, " "); err != nil { - t.Fatalf("scaffoldPromptConventionFolders: %v", err) - } - content, err := os.ReadFile(filepath.Join(dir, "instructions.md")) - if err != nil { - t.Fatalf("read instructions.md: %v", err) - } - if string(content) != "You are a helpful AI assistant.\n" { - t.Errorf("default instructions: got %q", string(content)) - } } -func TestScaffoldPromptConventionFolders_DoesNotOverwriteInstructions(t *testing.T) { - dir := t.TempDir() - existing := "MY EDITED INSTRUCTIONS\n" - if err := os.WriteFile(filepath.Join(dir, "instructions.md"), []byte(existing), 0o600); err != nil { - t.Fatalf("seed instructions.md: %v", err) - } - - if err := scaffoldPromptConventionFolders(dir, "should be ignored"); err != nil { - t.Fatalf("scaffoldPromptConventionFolders: %v", err) - } - - content, err := os.ReadFile(filepath.Join(dir, "instructions.md")) - if err != nil { - t.Fatalf("read instructions.md: %v", err) +// Instructions are written inline into agent.yaml, so a scaffold with nothing +// authored still produces a deployable agent rather than an empty prompt. +func TestPromptScaffoldInstructions_DefaultsWhenBlank(t *testing.T) { + if got := promptScaffoldInstructions(" "); got != "You are a helpful AI assistant." { + t.Errorf("default instructions: got %q", got) } - if string(content) != existing { - t.Errorf("existing instructions.md should be preserved, got %q", string(content)) + if got := promptScaffoldInstructions(" authored \n"); got != "authored" { + t.Errorf("authored instructions: got %q", got) } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go index 8484359b3d8..a5eabbb1bb4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go @@ -138,6 +138,10 @@ func (a *InvokeAction) runPromptInvoke(ctx context.Context, pctx *promptServiceC // The returned string is the response id parsed from the stream's lifecycle // events (when present), which the caller persists so the next invoke can // chain via `previous_response_id` for multi-turn memory. +// +// Terminal failure events (`error`, `response.failed`, `response.incomplete`) +// return an error. Reporting success with no output would make a failed +// invocation indistinguishable from an empty answer and exit 0 in CI. func streamManagedSSE(r io.Reader, w io.Writer) (string, error) { scanner := bufio.NewScanner(r) // SSE data lines can be large (full JSON payloads); raise the buffer cap @@ -146,6 +150,7 @@ func streamManagedSSE(r io.Reader, w io.Writer) (string, error) { var event string var responseID string + var streamErr error wroteText := false for scanner.Scan() { line := scanner.Text() @@ -154,7 +159,8 @@ func streamManagedSSE(r io.Reader, w io.Writer) (string, error) { event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) case strings.HasPrefix(line, "data:"): data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) - if event == "response.output_text.delta" { + switch { + case event == "response.output_text.delta": var payload struct { Delta string `json:"delta"` } @@ -162,7 +168,11 @@ func streamManagedSSE(r io.Reader, w io.Writer) (string, error) { fmt.Fprint(w, payload.Delta) wroteText = true } - } else if strings.HasPrefix(event, "response.") { + case event == "error" || event == "response.failed" || event == "response.incomplete": + if streamErr == nil { + streamErr = managedStreamFailure(event, data) + } + case strings.HasPrefix(event, "response."): // Capture the response id from any lifecycle event that carries // it (e.g. response.created, response.completed). The last one // seen wins so the persisted id reflects the completed turn. @@ -183,5 +193,45 @@ func streamManagedSSE(r io.Reader, w io.Writer) (string, error) { if wroteText { fmt.Fprintln(w) } - return responseID, scanner.Err() + if err := scanner.Err(); err != nil { + return responseID, err + } + return responseID, streamErr +} + +// managedStreamFailure builds an error from a terminal SSE event, preferring +// the service-supplied message over the raw payload. +func managedStreamFailure(event, data string) error { + var payload struct { + Message string `json:"message"` + Error struct { + Message string `json:"message"` + Code string `json:"code"` + } `json:"error"` + Response struct { + IncompleteDetails struct { + Reason string `json:"reason"` + } `json:"incomplete_details"` + Error struct { + Message string `json:"message"` + Code string `json:"code"` + } `json:"error"` + } `json:"response"` + } + _ = json.Unmarshal([]byte(data), &payload) + + for _, candidate := range []string{ + payload.Error.Message, + payload.Response.Error.Message, + payload.Message, + payload.Response.IncompleteDetails.Reason, + } { + if strings.TrimSpace(candidate) != "" { + return fmt.Errorf("%s: %s", event, candidate) + } + } + if strings.TrimSpace(data) != "" { + return fmt.Errorf("%s: %s", event, data) + } + return fmt.Errorf("the agent run ended with %q and produced no response", event) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_stream_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_stream_test.go new file mode 100644 index 00000000000..2c9259e075c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_stream_test.go @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "io" + "strings" + "testing" +) + +// TestStreamManagedSSE_TerminalEvents asserts a failed harness run is reported +// as an error. Returning nil here would make `azd ai agent invoke` exit 0 with +// no output, which is indistinguishable from an empty answer in CI. +func TestStreamManagedSSE_TerminalEvents(t *testing.T) { + tests := []struct { + name string + stream string + wantErr bool + wantSub string + }{ + { + name: "error event", + stream: "event: error\n" + + `data: {"error":{"message":"model deployment not found","code":"NotFound"}}` + "\n\n", + wantErr: true, + wantSub: "model deployment not found", + }, + { + name: "response.failed", + stream: "event: response.failed\n" + + `data: {"response":{"id":"resp_1","error":{"message":"tool call failed"}}}` + "\n\n", + wantErr: true, + wantSub: "tool call failed", + }, + { + name: "response.incomplete", + stream: "event: response.incomplete\n" + + `data: {"response":{"id":"resp_2","incomplete_details":{"reason":"max_output_tokens"}}}` + "\n\n", + wantErr: true, + wantSub: "max_output_tokens", + }, + { + name: "terminal event with no details", + stream: "event: error\n" + + "data: \n\n", + wantErr: true, + wantSub: "produced no response", + }, + { + name: "successful run", + stream: "event: response.created\n" + + `data: {"response":{"id":"resp_3"}}` + "\n\n" + + "event: response.output_text.delta\n" + + `data: {"delta":"hello"}` + "\n\n" + + "event: response.completed\n" + + `data: {"response":{"id":"resp_3"}}` + "\n\n", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var sb strings.Builder + _, err := streamManagedSSE(strings.NewReader(tt.stream), &sb) + if tt.wantErr { + if err == nil { + t.Fatal("expected an error") + } + if tt.wantSub != "" && !strings.Contains(err.Error(), tt.wantSub) { + t.Errorf("error %q does not contain %q", err.Error(), tt.wantSub) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +// TestStreamManagedSSE_ReturnsResponseID confirms the response id is captured +// so the next invoke can chain via previous_response_id. +func TestStreamManagedSSE_ReturnsResponseID(t *testing.T) { + stream := "event: response.completed\n" + `data: {"response":{"id":"resp_abc"}}` + "\n\n" + id, err := streamManagedSSE(strings.NewReader(stream), io.Discard) + if err != nil { + t.Fatalf("streamManagedSSE: %v", err) + } + if id != "resp_abc" { + t.Errorf("response id: got %q, want resp_abc", id) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index b8f41ed496e..911a028d925 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -48,6 +48,9 @@ func configureExtensionHost(host *azdext.ExtensionHost) { WithServiceEventHandler("postdeploy", func(ctx context.Context, args *azdext.ServiceEventArgs) error { return postdeployHandler(ctx, azdClient, args) }, &azdext.ServiceEventOptions{Host: AiAgentHost}). + WithProjectEventHandler("predown", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + return predownHandler(ctx, azdClient, args) + }). WithProjectEventHandler("postdown", func(ctx context.Context, args *azdext.ProjectEventArgs) error { return postdownHandler(ctx, azdClient, args) }) @@ -280,13 +283,19 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az return err } - if err := prepareContainerSettings( - ctx, - azdClient, - svc, - args.Project.Path, - ); err != nil { - return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) + // Prompt (kind=managed) agents have no container settings — the harness owns + // the runtime. Without this guard SetAgentContainerSettings writes default + // memory/cpu onto the service and persists them into azure.yaml for an agent + // azd does not host. + if _, isPrompt := promptSettingsFromService(svc); !isPrompt { + if err := prepareContainerSettings( + ctx, + azdClient, + svc, + args.Project.Path, + ); err != nil { + return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) + } } if err := envUpdate( ctx, @@ -519,13 +528,6 @@ func postdownHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azd continue } - // Prompt (kind=managed) agents are removed from the harness on down so - // `azd down` fully tears down the agent alongside the infrastructure. - // Best-effort: a harness failure is logged but does not block down. - if settings, isPrompt := promptSettingsFromService(svc); isPrompt { - deletePromptAgentOnDown(ctx, svc, settings) - } - if cleanupAgentSessionState(ctx, azdClient, envName, svc.Name) { fmt.Printf("Cleaned up saved session and conversation for agent %q\n", svc.Name) } @@ -538,6 +540,33 @@ func postdownHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azd return nil } +// predownHandler removes prompt (kind=managed) agents from the harness before +// `azd down` tears the infrastructure away. It deliberately runs at predown +// rather than postdown: the Foundry project/workspace that provides the harness +// route is already gone by postdown, so the delete would report success while +// leaving the agent behind. +// +// Best-effort throughout — a harness failure is logged but never blocks down. +func predownHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { + envValues, envErr := promptEnvValues(ctx, azdClient) + if envErr != nil { + log.Printf("predown: failed to read the azd environment: %v", envErr) + } + + for _, svc := range args.Project.Services { + if svc.Host != AiAgentHost { + continue + } + settings, isPrompt := promptSettingsFromService(svc) + if !isPrompt { + continue + } + deletePromptAgentOnDown(ctx, svc, settings, args.Project.Path, envValues) + } + + return nil +} + // deletePromptAgentOnDown best-effort deletes a prompt agent from the harness // during `azd down`. Failures are logged, never returned — teardown of the // project should not be blocked by a harness hiccup. @@ -545,22 +574,38 @@ func deletePromptAgentOnDown( ctx context.Context, svc *azdext.ServiceConfig, settings *project.PromptAgentSettings, + projectPath string, + envValues map[string]string, ) { settings.ApplyEnvOverrides() if err := settings.Validate(); err != nil { - log.Printf("postdown: skipping harness delete for %q: %v", svc.Name, err) + log.Printf("predown: skipping harness delete for %q: %v", svc.Name, err) return } + // Apply the same azd environment-derived target resolution deploy and the + // other lifecycle commands use. Without it a non-guided project keeps the + // placeholder workspace tuple from azure.yaml and the delete is routed at a + // workspace that never existed. + if envValues != nil { + if _, mapErr := project.ResolvePromptTargetFromEnv(settings, envValues); mapErr != nil { + log.Printf("predown: skipping harness delete for %q: %v", svc.Name, mapErr) + return + } + } + // Delete by the agent.yaml name — the identity every other prompt lifecycle + // path uses. The azure.yaml service key only matches when agent.yaml omits + // `name:`, which is true for scaffolded projects but not for renamed agents. + agentName := promptAgentNameForService(svc, projectPath) client, err := project.NewPromptAgentClient(settings) if err != nil { - log.Printf("postdown: failed to build harness client for %q: %v", svc.Name, err) + log.Printf("predown: failed to build harness client for %q: %v", svc.Name, err) return } - if _, err := client.DeleteAgent(ctx, svc.Name, settings.EffectiveAPIVersion(), true); err != nil { - log.Printf("postdown: failed to delete prompt agent %q from harness: %v", svc.Name, err) + if _, err := client.DeleteAgent(ctx, agentName, settings.EffectiveAPIVersion(), true); err != nil { + log.Printf("predown: failed to delete prompt agent %q from harness: %v", agentName, err) return } - fmt.Printf("Deleted prompt agent %q from the harness\n", svc.Name) + fmt.Printf("Deleted prompt agent %q from the harness\n", agentName) } // cleanupAgentSessionState removes saved session and conversation IDs for a diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go index 72dfc0792a6..23851844c58 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go @@ -109,6 +109,30 @@ func resolvePromptAgentService( return pctx, true, nil } +// promptAgentNameForService returns the harness agent identity for a prompt +// service: the `name` declared in its agent.yaml, falling back to the +// azure.yaml service key when agent.yaml is absent or declares no name. It is +// the lightweight counterpart of promptServiceContext.AgentName for callers +// (like the down handlers) that only have a ServiceConfig. +func promptAgentNameForService(svc *azdext.ServiceConfig, projectPath string) string { + if svc == nil { + return "" + } + dir, err := paths.JoinAllowRoot(projectPath, svc.RelativePath) + if err != nil { + return svc.Name + } + data, err := os.ReadFile(filepath.Join(dir, "agent.yaml")) + if err != nil { + return svc.Name + } + var def agent_yaml.PromptAgent + if err := yaml.Unmarshal(data, &def); err != nil || strings.TrimSpace(def.Name) == "" { + return svc.Name + } + return def.Name +} + // AgentName returns the harness agent identity for the resolved service. func (p *promptServiceContext) AgentName() string { if p.Agent.Name != "" { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go index 0931fbd76fb..0e2d9f6a40d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -33,10 +33,49 @@ const ( // aiProjectServiceName is the stable azure.yaml service key used for the // single azure.ai.project service. A stable name keeps repeated inits // idempotent (AddService overwrites by name) so there is one project - // service per project, matching the unified Foundry config design. + // service per project, matching the unified Foundry config design. It is + // deliberately generic rather than derived from the Foundry project name so + // azure.yaml carries no tenant-specific identifiers and can be copied + // between projects unchanged. aiProjectServiceName = "ai-project" + + // projectEndpointEnvVar carries the concrete Foundry project endpoint in the + // azd environment. azure.yaml references it instead of embedding the URL so + // the project stays portable: set it to reuse an existing project, leave it + // unset to have `azd provision` create a new one. + projectEndpointEnvVar = "AZURE_AI_PROJECT_ENDPOINT" + + // projectEndpointRef is the portable reference written as endpoint: on the + // azure.ai.project service. Synthesize expands it before deciding + // brownfield vs greenfield, so an unset variable resolves to "" (greenfield). + projectEndpointRef = "${" + projectEndpointEnvVar + "}" + + // projectWorkspaceEnvVar carries the AML workspace name backing the Foundry + // project (@@AML). The managed control plane's agent + // routes are workspace-scoped, so the promptAgent block references it + // instead of embedding the tenant-specific name in azure.yaml. + projectWorkspaceEnvVar = "AZURE_AI_WORKSPACE" ) +// promptAgentEnvRefs returns the promptAgent block `azd ai agent init` writes +// into azure.yaml. Every field is a ${VAR} reference rather than a literal, so +// the file carries no subscription, resource group, or workspace of its own and +// can be copied between Foundry projects unchanged: `azd up` in a new +// environment resolves each field from that environment. +// +// The deploy path expands these references against the azd environment and +// falls back to the built-in defaults for any variable that is unset, so a +// project cloned without an environment still initializes. +func promptAgentEnvRefs() *project.PromptAgentSettings { + return &project.PromptAgentSettings{ + BaseURL: "${" + project.PromptBaseURLEnvVar + "}", + SubscriptionID: "${AZURE_SUBSCRIPTION_ID}", + ResourceGroup: "${AZURE_RESOURCE_GROUP}", + Workspace: "${" + projectWorkspaceEnvVar + "}", + ProjectEndpoint: projectEndpointRef, + } +} + // emitResourceServices writes the Foundry resource sibling services that the // agent depends on (one azure.ai.project carrying the model deployments, one // azure.ai.connection per connection, one azure.ai.toolbox per toolbox) and @@ -46,15 +85,12 @@ const ( // projectEndpoint, when non-empty, is written as endpoint: on the project // service to mark an existing (brownfield) Foundry project so provision // connects to it instead of creating a new one. It is empty for new projects. -// -// projectName, when known, is the Foundry project name used to derive the -// project service key (so azure.yaml reads like the real project). It falls back -// to aiProjectServiceName when unknown or colliding. See resolveProjectServiceKey. +// Callers pass projectEndpointRef (not a literal URL) so azure.yaml stays +// portable; see recordFoundryProjectEnv. func emitResourceServices( ctx context.Context, azdClient *azdext.AzdClient, agentServiceName string, - projectName string, projectEndpoint string, deployments []project.Deployment, connections []project.Connection, @@ -95,7 +131,7 @@ func emitResourceServices( if err != nil { return fmt.Errorf("marshaling project service config: %w", err) } - projectServiceName := resolveProjectServiceKey(ctx, azdClient, projectName, agentServiceName) + projectServiceName := resolveProjectServiceKey(ctx, azdClient) if err := reserveServiceName(usedNames, projectServiceName, "project service"); err != nil { return err } @@ -171,26 +207,20 @@ func emitResourceServices( // project. This keeps repeated inits idempotent (azd's extension API has no // remove-service call, so a changed key would leave a second project service // behind, which the provisioning provider rejects). -// 2. Otherwise derive the key from the Foundry project name when it is known and -// does not collide with the agent service name, so azure.yaml reads like the -// real project. -// 3. Otherwise fall back to the stable "ai-project" default. +// 2. Otherwise use the generic "ai-project" key. // -// The key is not load-bearing: the provider and collectors find the project -// service by host (azure.ai.project), and the generated uses: edges reference -// whatever key this returns. +// The key is deliberately not derived from the Foundry project name: a +// tenant-specific key makes azure.yaml non-portable, and the key is not +// load-bearing anyway -- the provider and collectors find the project service by +// host (azure.ai.project), and the generated uses: edges reference whatever key +// this returns. func resolveProjectServiceKey( ctx context.Context, azdClient *azdext.AzdClient, - projectName string, - agentServiceName string, ) string { if existing := existingProjectServiceKey(ctx, azdClient); existing != "" { return existing } - if key := sanitizeServiceName(projectName); key != "" && key != agentServiceName { - return key - } return aiProjectServiceName } @@ -216,41 +246,50 @@ func existingProjectServiceKey(ctx context.Context, azdClient *azdext.AzdClient) return keys[0] } -// projectNameHint returns the Foundry project name to derive the project service -// key from: the selected existing project's name, else the AZURE_AI_PROJECT_NAME -// azd environment value when concretely set (not a ${...} placeholder), else "". -func projectNameHint( +// recordFoundryProjectEnv stores the concrete Foundry project coordinates that +// azure.yaml only references by name -- the data-plane endpoint and the backing +// AML workspace -- in the azd environment, and returns the portable ${VAR} +// reference to write as endpoint: on the project service. +// +// A nil or incomplete project (the "create a new project" path) writes nothing +// and returns "", leaving the project service greenfield. +func recordFoundryProjectEnv( ctx context.Context, azdClient *azdext.AzdClient, envName string, - selected *FoundryProjectInfo, -) string { - if selected != nil && selected.ProjectName != "" { - return selected.ProjectName + foundryProject *FoundryProjectInfo, +) (string, error) { + endpoint := strings.TrimSpace(foundryProject.Endpoint()) + if endpoint == "" { + return "", nil } - v, err := getEnvValue(ctx, azdClient, envName, "AZURE_AI_PROJECT_NAME") - if err != nil || strings.HasPrefix(strings.TrimSpace(v), "${") { - return "" + if err := setEnvValue(ctx, azdClient, envName, projectEndpointEnvVar, endpoint); err != nil { + return "", fmt.Errorf("recording %s: %w", projectEndpointEnvVar, err) } - return v + // Managed agent CRUD routes are workspace-scoped; for Foundry projects the + // backing AML workspace name is @@AML. + workspace := fmt.Sprintf("%s@%s@AML", foundryProject.AccountName, foundryProject.ProjectName) + if err := setEnvValue(ctx, azdClient, envName, projectWorkspaceEnvVar, workspace); err != nil { + return "", fmt.Errorf("recording %s: %w", projectWorkspaceEnvVar, err) + } + return projectEndpointRef, nil } -// stampProjectEndpoint writes the selected project's endpoint onto the existing -// azure.ai.project service in azure.yaml. This is a no-op when the project is -// nil, has no endpoint, or when no ai-project service exists yet. -func stampProjectEndpoint(ctx context.Context, azdClient *azdext.AzdClient, selectedProject *FoundryProjectInfo) error { - if selectedProject == nil { - return nil - } - endpoint := selectedProject.Endpoint() - if endpoint == "" { +// stampProjectEndpoint writes endpointRef as endpoint: on the existing +// azure.ai.project service in azure.yaml. Callers pass the portable +// ${AZURE_AI_PROJECT_ENDPOINT} reference returned by recordFoundryProjectEnv, not +// a literal URL. This is a no-op when endpointRef is empty (a new project) or +// when no azure.ai.project service exists yet. +func stampProjectEndpoint(ctx context.Context, azdClient *azdext.AzdClient, endpointRef string) error { + endpointRef = strings.TrimSpace(endpointRef) + if endpointRef == "" { return nil } projectSvcKey := existingProjectServiceKey(ctx, azdClient) if projectSvcKey == "" { return nil } - endpointVal, err := structpb.NewValue(endpoint) + endpointVal, err := structpb.NewValue(endpointRef) if err != nil { return fmt.Errorf("encoding project endpoint: %w", err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go index d5851a0d38c..efb9df200d7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -424,7 +424,7 @@ func TestEmitResourceServices_AlwaysEmitsProjectService(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - err := emitResourceServices(t.Context(), client, "myagent", "", "", nil, nil, nil) + err := emitResourceServices(t.Context(), client, "myagent", "", nil, nil, nil) require.NoError(t, err) server.mu.Lock() @@ -446,7 +446,7 @@ func TestEmitResourceServices_WiresSiblingsToProject(t *testing.T) { client := newProjectRecorderClient(t, server) conns := []project.Connection{{Name: "myconn", Category: "ApiKey"}} - err := emitResourceServices(t.Context(), client, "myagent", "", "", nil, conns, nil) + err := emitResourceServices(t.Context(), client, "myagent", "", nil, conns, nil) require.NoError(t, err) server.mu.Lock() @@ -479,7 +479,7 @@ func TestEmitResourceServices_WritesServiceLevelProps(t *testing.T) { Sku: project.DeploymentSku{Name: "GlobalStandard", Capacity: 10}, }} conns := []project.Connection{{Name: "myconn", Category: "ApiKey", Target: "https://example", AuthType: "ApiKey"}} - require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", "", deployments, conns, nil)) + require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", deployments, conns, nil)) server.mu.Lock() defer server.mu.Unlock() @@ -512,17 +512,17 @@ func TestEmitResourceServices_WritesServiceLevelProps(t *testing.T) { // TestEmitResourceServices_WritesEndpointForExistingProject verifies that a // non-empty projectEndpoint is written as endpoint: on the ai-project service // (the brownfield signal provision reads to reuse the project) and that an -// empty endpoint (new project) leaves the field unset. +// empty endpoint (new project) leaves the field unset. Callers pass the +// portable ${AZURE_AI_PROJECT_ENDPOINT} reference, never a literal URL. func TestEmitResourceServices_WritesEndpointForExistingProject(t *testing.T) { t.Parallel() - const endpoint = "https://acct.services.ai.azure.com/api/projects/proj" - t.Run("existing project writes endpoint", func(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", endpoint, nil, nil, nil)) + require.NoError(t, emitResourceServices( + t.Context(), client, "myagent", projectEndpointRef, nil, nil, nil)) server.mu.Lock() defer server.mu.Unlock() @@ -531,14 +531,17 @@ func TestEmitResourceServices_WritesEndpointForExistingProject(t *testing.T) { projSvc := server.added[0] require.Equal(t, aiProjectServiceName, projSvc.Name) require.NotNil(t, projSvc.AdditionalProperties) - assert.Equal(t, endpoint, projSvc.AdditionalProperties.Fields["endpoint"].GetStringValue()) + assert.Equal(t, + "${AZURE_AI_PROJECT_ENDPOINT}", + projSvc.AdditionalProperties.Fields["endpoint"].GetStringValue(), + ) }) t.Run("new project omits endpoint", func(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", "", nil, nil, nil)) + require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", nil, nil, nil)) server.mu.Lock() defer server.mu.Unlock() @@ -553,23 +556,23 @@ func TestEmitResourceServices_WritesEndpointForExistingProject(t *testing.T) { } // TestEmitResourceServices_ProjectServiceKey verifies how the azure.ai.project -// service key is resolved: reuse an existing key, else derive from the project -// name, else fall back to "ai-project". +// service key is resolved: reuse an existing key, else the generic "ai-project". +// The key is never derived from the Foundry project name -- azure.yaml must not +// carry tenant-specific identifiers. func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { t.Parallel() - t.Run("derives key from project name", func(t *testing.T) { + t.Run("uses the generic key for a new project", func(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "my-foundry-proj", "", nil, nil, nil)) + require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", nil, nil, nil)) server.mu.Lock() defer server.mu.Unlock() require.Len(t, server.added, 1) - assert.Equal(t, "my-foundry-proj", server.added[0].Name) - assert.Equal(t, []string{"my-foundry-proj"}, server.uses["myagent"]) + assert.Equal(t, aiProjectServiceName, server.added[0].Name) + assert.Equal(t, []string{aiProjectServiceName}, server.uses["myagent"]) }) t.Run("reuses existing project service key", func(t *testing.T) { @@ -580,75 +583,13 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { } client := newProjectRecorderClient(t, server) - // A different project name is supplied, but the existing key wins so a - // repeated init does not create a second project service. - require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "a-new-name", "", nil, nil, nil)) + // The existing key wins so a repeated init does not create a second + // project service. + require.NoError(t, emitResourceServices(t.Context(), client, "myagent", "", nil, nil, nil)) server.mu.Lock() defer server.mu.Unlock() require.Len(t, server.added, 1) assert.Equal(t, "old-project-key", server.added[0].Name) }) - - t.Run("falls back when project name collides with agent", func(t *testing.T) { - server := &recordingProjectServer{} - client := newProjectRecorderClient(t, server) - - require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "my agent", "", nil, nil, nil)) - - server.mu.Lock() - defer server.mu.Unlock() - require.Len(t, server.added, 1) - // "my agent" sanitizes to "myagent" == agent key, so it falls back. - assert.Equal(t, aiProjectServiceName, server.added[0].Name) - }) - - t.Run("falls back when project name unknown", func(t *testing.T) { - server := &recordingProjectServer{} - client := newProjectRecorderClient(t, server) - - require.NoError(t, emitResourceServices( - t.Context(), client, "myagent", "", "", nil, nil, nil)) - - server.mu.Lock() - defer server.mu.Unlock() - require.Len(t, server.added, 1) - assert.Equal(t, aiProjectServiceName, server.added[0].Name) - }) -} - -// TestProjectNameHint verifies the project-name hint resolution: a selected -// existing project's name wins, else AZURE_AI_PROJECT_NAME when concretely set, -// else "" (unknown). -func TestProjectNameHint(t *testing.T) { - t.Parallel() - const envName = "dev" - - newClient := func(t *testing.T, vals map[string]string) *azdext.AzdClient { - env := &testEnvironmentServiceServer{values: map[string]map[string]string{envName: vals}} - return newTestAzdClient(t, env, &testWorkflowServiceServer{}) - } - - t.Run("selected project name wins", func(t *testing.T) { - client := newClient(t, map[string]string{"AZURE_AI_PROJECT_NAME": "from-env"}) - got := projectNameHint(t.Context(), client, envName, &FoundryProjectInfo{ProjectName: "from-selected"}) - assert.Equal(t, "from-selected", got) - }) - - t.Run("falls back to env when no selection", func(t *testing.T) { - client := newClient(t, map[string]string{"AZURE_AI_PROJECT_NAME": "from-env"}) - assert.Equal(t, "from-env", projectNameHint(t.Context(), client, envName, nil)) - }) - - t.Run("placeholder env value yields empty", func(t *testing.T) { - client := newClient(t, map[string]string{"AZURE_AI_PROJECT_NAME": "${AZURE_AI_PROJECT_NAME}"}) - assert.Equal(t, "", projectNameHint(t.Context(), client, envName, nil)) - }) - - t.Run("missing env value yields empty", func(t *testing.T) { - client := newClient(t, map[string]string{}) - assert.Equal(t, "", projectNameHint(t.Context(), client, envName, nil)) - }) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go index 429a9d398b6..8dcce19e84b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go @@ -255,8 +255,8 @@ func printPromptShowTable(agent *agent_api.AgentObject, settings *projectpkg.Pro def := promptDefinitionMap(latest) // Harness is the execution harness the platform runs the agent on, taken - // from the deployed definition's `harness` field (e.g. "ghcp"). The - // previous implementation printed settings.BaseURL here, which is the + // from the deployed definition's `harness` field (e.g. "github-copilot"). + // The previous implementation printed settings.BaseURL here, which is the // harness *API base URL*, not the harness itself. if harness := stringFromMap(def, "harness"); harness != "" { fmt.Fprintf(w, "Harness:\t%s\n", displayHarness(harness)) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go index 874739ffd84..e1e7552cebd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go @@ -402,15 +402,15 @@ func TestResolveNextStepFromStatus_NonActiveBranches(t *testing.T) { } func TestDisplayHarness(t *testing.T) { - assert.Equal(t, "GitHub Copilot (ghcp)", displayHarness("ghcp")) + assert.Equal(t, "GitHub Copilot (github-copilot)", displayHarness("github-copilot")) assert.Equal(t, "custom-harness", displayHarness("custom-harness")) } func TestPromptDefinitionMap(t *testing.T) { version := agent_api.AgentVersionObject{ - Definition: map[string]any{"harness": "ghcp"}, + Definition: map[string]any{"harness": "github-copilot"}, } - assert.Equal(t, "ghcp", stringFromMap(promptDefinitionMap(version), "harness")) + assert.Equal(t, "github-copilot", stringFromMap(promptDefinitionMap(version), "harness")) // Non-map definition yields nil, and stringFromMap tolerates nil. assert.Nil(t, promptDefinitionMap(agent_api.AgentVersionObject{Definition: "not-a-map"})) diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index bfe363d1b52..17e6053c00f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -130,8 +130,7 @@ const ( // Error codes for toolbox operations. const ( - CodeInvalidToolbox = "invalid_toolbox" - CodeCreateToolboxVersionFailed = "create_toolbox_version_failed" + CodeInvalidToolbox = "invalid_toolbox" ) // Error codes for connection operations. @@ -188,7 +187,6 @@ const ( OpDeleteSession = "delete_session" OpStopSession = "stop_session" OpListSessions = "list_sessions" - OpCreateToolboxVersion = "create_toolbox_version" OpGetToolbox = "get_toolbox" OpProvisionMemoryStore = "provision_memory_store" ) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go index c11188c2ae5..d04675e59f2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "net/url" "strconv" @@ -71,6 +72,19 @@ type ManagedAgentClientOptions struct { Scopes []string } +// isLoopbackHost reports whether host names the local machine, and is used to +// decide whether bearer credentials may be sent over plaintext http. Only a +// literal loopback address (or "localhost") qualifies; anything resolvable to +// another machine must use TLS. +func isLoopbackHost(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + // NewManagedAgentClient builds a ManagedAgentClient from the given options. // Returns an error when BaseURL or RoutePrefix is malformed. func NewManagedAgentClient(opts ManagedAgentClientOptions) (*ManagedAgentClient, error) { @@ -105,10 +119,20 @@ func NewManagedAgentClient(opts ManagedAgentClientOptions) (*ManagedAgentClient, // The local managed-harness is served over plain HTTP // (http://localhost:5000) but still validates a bearer token. azcore // refuses to attach credentials to non-TLS endpoints unless this is - // explicitly opted into, so allow it when (and only when) the base URL - // is http — production https endpoints keep the default protection. + // explicitly opted into. Gate that opt-in on the *host*, not the scheme: + // a shared dev backend, a typo, or a stray config value pointing at + // http://some-host would otherwise put an Entra token scoped to + // https://ai.azure.com/.default on the wire in the clear, where it can be + // captured and replayed. var bearerOpts *policy.BearerTokenOptions if parsed.Scheme == "http" { + if !isLoopbackHost(parsed.Hostname()) { + return nil, fmt.Errorf( + "ManagedAgentClient: refusing to send Azure credentials over plaintext http to %q; "+ + "use https, or target a loopback address for local development", + parsed.Host, + ) + } bearerOpts = &policy.BearerTokenOptions{ InsecureAllowCredentialWithHTTP: true, } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index 9f3f9a83244..e034a9de697 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -307,21 +307,81 @@ type ManagedEnvironment struct { // ManagedAgentHarnessGitHubCopilot is the execution harness identifier sent in // the managed agent definition's `harness` field to run the agent on the // GitHub Copilot harness. -const ManagedAgentHarnessGitHubCopilot = "ghcp" +const ManagedAgentHarnessGitHubCopilot = "github-copilot" + +// ManagedAgentHarnessGitHubCopilotRemoved is the abbreviated spelling this +// harness used previously. It is retained only so validation can name the +// replacement when it encounters an old manifest; it is never sent on the wire +// and never accepted as input. +const ManagedAgentHarnessGitHubCopilotRemoved = "ghcp" + +// HarnessSkillReference pins one published Foundry skill onto a harnessed +// agent's definition. +// +// Version is not optional in practice. The API contract says an omitted version +// resolves to the skill's current default, but the service currently returns a +// 500 for a reference without one, so callers must supply the version they +// published. Skills are published before the agent version is created, so the +// version is always known by then. +type HarnessSkillReference struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` +} + +// ManagedAgentHarness is the `harness` block of a managed agent definition. +// +// Skills hang off the harness rather than off the definition because a skill is +// instructions plus scripts and assets: it needs the harness execution +// environment to run at all. Foundry provisions each pinned skill into the +// sandbox when it starts. A definition-level `skills` field would imply that a +// prompt agent with no harness could execute one, which it cannot -- the +// service accepts that field but never resolves it, so a name written there is +// silently inert (including a name that matches no skill at all). +type ManagedAgentHarness struct { + Type string `json:"type"` + Skills []HarnessSkillReference `json:"skills,omitempty"` +} + +// UnmarshalJSON accepts either the object form or the bare string form the +// harness was originally sent as, so definitions read back from agents created +// by earlier versions of azd still decode. +func (h *ManagedAgentHarness) UnmarshalJSON(data []byte) error { + var asString string + if err := json.Unmarshal(data, &asString); err == nil { + h.Type = asString + h.Skills = nil + return nil + } + type harnessAlias ManagedAgentHarness + var alias harnessAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + *h = ManagedAgentHarness(alias) + return nil +} -// ManagedAgentDefinition represents a Foundry "managed" agent backed by the -// Prompt Execution Service (PES). Managed agents declare a model + instructions -// and optionally tools, skills, and environment overrides. The platform -// provisions Brain+Hand sandboxes on demand to execute the agent. +// ManagedAgentDefinition represents a Foundry "managed" agent — a prompt agent +// that names an execution harness. Managed agents declare a model plus +// instructions and optionally tools, skills, and environment overrides. The +// platform provisions Brain+Hand sandboxes on demand to execute the agent. type ManagedAgentDefinition struct { AgentDefinition Model string `json:"model"` // Harness identifies the execution harness the platform should use to run - // the managed agent (e.g. "ghcp" for the GitHub Copilot harness). - Harness string `json:"harness,omitempty"` - Instructions string `json:"instructions,omitempty"` - Tools []any `json:"tools,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` + // the managed agent, and carries the skills provisioned into it. Nil for a + // plain prompt agent, which Foundry runs directly. + Harness *ManagedAgentHarness `json:"harness,omitempty"` + Instructions string `json:"instructions,omitempty"` + Tools []any `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Text any `json:"text,omitempty"` + Reasoning any `json:"reasoning,omitempty"` + // Skills is the definition-level skill list. It applies only to a + // harness-less prompt agent; a harnessed agent carries its skills on + // Harness.Skills instead. Skills []string `json:"skills,omitempty"` StructuredInputs map[string]any `json:"structured_inputs,omitempty"` Environment *ManagedEnvironment `json:"environment,omitempty"` diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go index f41dcec226e..e7cda264d40 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go @@ -79,7 +79,8 @@ func TestPromptAgent_YAMLRoundTrip(t *testing.T) { t.Fatalf("unmarshal: %v", err) } if roundTripped.Model != original.Model { - t.Errorf("model: got %q, want %q", roundTripped.Model, original.Model) + t.Errorf("model: got %q, want %q", + roundTripped.Model, original.Model) } if roundTripped.Instructions != original.Instructions { t.Errorf("instructions: got %q, want %q", roundTripped.Instructions, original.Instructions) @@ -90,9 +91,8 @@ func TestPromptAgent_YAMLRoundTrip(t *testing.T) { } // TestValidateAgentDefinition_Prompt_RequiresModelAndInstructions ensures the -// validator requires a model for prompt agents. Instructions are intentionally -// not required inline (they may come from a sibling instructions.md), so an -// agent.yaml without inline instructions must still validate here. +// validator requires both a model deployment and inline instructions for prompt +// agents — the two fields the prompt-agent API cannot default. func TestValidateAgentDefinition_Prompt_RequiresModelAndInstructions(t *testing.T) { cases := []struct { name string @@ -111,13 +111,14 @@ instructions: ok shouldError: true, }, { - name: "missing inline instructions is allowed (may come from instructions.md)", + name: "missing instructions", yamlContent: ` name: n kind: prompt model: gpt-4.1-mini `, - shouldError: false, + wantSubstr: "instructions", + shouldError: true, }, { name: "valid", @@ -149,38 +150,147 @@ instructions: Be helpful. } } -// TestCreatePromptAgentAPIRequest_SetsHarness verifies the prompt create -// request carries the GitHub Copilot harness identifier in the definition. -func TestCreatePromptAgentAPIRequest_SetsHarness(t *testing.T) { +// TestCreatePromptAgentAPIRequest_Harness verifies the prompt create request +// carries the agent's harness verbatim, and that a plain (harness-less) prompt +// agent omits the field entirely rather than defaulting to a harness. +func TestCreatePromptAgentAPIRequest_Harness(t *testing.T) { + tests := []struct { + name string + harness string + wantHarness string + wantJSON bool + }{ + { + name: "managed agent keeps the GitHub Copilot harness", + harness: agent_api.ManagedAgentHarnessGitHubCopilot, + wantHarness: agent_api.ManagedAgentHarnessGitHubCopilot, + wantJSON: true, + }, + { + name: "plain prompt agent has no harness", + harness: "", + wantHarness: "", + wantJSON: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + promptDef := PromptAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindPrompt, + Name: "my-agent", + }, + Model: "gpt-4.1-mini", + Harness: tc.harness, + Instructions: "Be helpful.", + } + + req, err := CreatePromptAgentAPIRequest(promptDef, nil) + if err != nil { + t.Fatalf("CreatePromptAgentAPIRequest: %v", err) + } + + def, ok := req.Definition.(agent_api.ManagedAgentDefinition) + if !ok { + t.Fatalf("definition: got %T, want agent_api.ManagedAgentDefinition", req.Definition) + } + gotHarness := "" + if def.Harness != nil { + gotHarness = def.Harness.Type + } + if gotHarness != tc.wantHarness { + t.Errorf("harness: got %q, want %q", gotHarness, tc.wantHarness) + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + gotJSON := strings.Contains(string(data), `"harness":`) + if gotJSON != tc.wantJSON { + t.Errorf("serialized harness field present = %v, want %v:\n%s", gotJSON, tc.wantJSON, data) + } + }) + } +} + +// TestCreatePromptAgentAPIRequest_HarnessSkills pins where skills land on the +// wire. A harnessed agent carries them inside the harness block as versioned +// references, because a skill is instructions plus scripts that only the +// harness sandbox can execute. Nothing about a skill becomes a tool, and no +// toolbox is involved: the harness already has a service-owned system toolbox +// whose name and lifecycle the customer does not manage. +func TestCreatePromptAgentAPIRequest_HarnessSkills(t *testing.T) { promptDef := PromptAgent{ - AgentDefinition: AgentDefinition{ - Kind: AgentKindPrompt, - Name: "my-agent", + AgentDefinition: AgentDefinition{Kind: AgentKindPrompt, Name: "my-agent"}, + Model: "gpt-4.1-mini", + Instructions: "Be helpful.", + Harness: agent_api.ManagedAgentHarnessGitHubCopilot, + Skills: []string{"duplicate-check"}, + HarnessSkills: []HarnessSkillRef{ + {Name: "duplicate-check", Version: "3"}, + {Name: "severity-triage", Version: "1"}, }, - Model: "gpt-4.1-mini", - Instructions: "Be helpful.", } req, err := CreatePromptAgentAPIRequest(promptDef, nil) if err != nil { t.Fatalf("CreatePromptAgentAPIRequest: %v", err) } - def, ok := req.Definition.(agent_api.ManagedAgentDefinition) if !ok { t.Fatalf("definition: got %T, want agent_api.ManagedAgentDefinition", req.Definition) } - if def.Harness != agent_api.ManagedAgentHarnessGitHubCopilot { - t.Errorf("harness: got %q, want %q", def.Harness, agent_api.ManagedAgentHarnessGitHubCopilot) + + if def.Harness == nil { + t.Fatal("expected a harness block") + } + want := []agent_api.HarnessSkillReference{ + {Name: "duplicate-check", Version: "3"}, + {Name: "severity-triage", Version: "1"}, + } + if len(def.Harness.Skills) != len(want) { + t.Fatalf("harness skills: got %+v, want %+v", def.Harness.Skills, want) + } + for i, w := range want { + if def.Harness.Skills[i] != w { + t.Errorf("harness skill %d: got %+v, want %+v", i, def.Harness.Skills[i], w) + } + } + if len(def.Skills) != 0 { + t.Errorf("harnessed skills must not appear on the definition-level field, got %+v", def.Skills) + } + if len(def.Tools) != 0 { + t.Errorf("a skill must not become a tool, got %+v", def.Tools) + } +} + +// TestCreatePromptAgentAPIRequest_HarnessLessSkills covers the other half of +// the split: with no harness there is no sandbox to provision skills into, so +// the authored names stay on the definition-level field. +func TestCreatePromptAgentAPIRequest_HarnessLessSkills(t *testing.T) { + promptDef := PromptAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindPrompt, Name: "my-agent"}, + Model: "gpt-4.1-mini", + Instructions: "Be helpful.", + Skills: []string{"severity-triage"}, } - // The serialized body must include "harness":"ghcp". - data, err := json.Marshal(req) + req, err := CreatePromptAgentAPIRequest(promptDef, nil) if err != nil { - t.Fatalf("marshal request: %v", err) + t.Fatalf("CreatePromptAgentAPIRequest: %v", err) + } + def, ok := req.Definition.(agent_api.ManagedAgentDefinition) + if !ok { + t.Fatalf("definition: got %T, want agent_api.ManagedAgentDefinition", req.Definition) + } + + if def.Harness != nil { + t.Errorf("expected no harness block, got %+v", def.Harness) } - if !strings.Contains(string(data), `"harness":"ghcp"`) { - t.Errorf("serialized request missing harness field:\n%s", data) + if len(def.Skills) != 1 || def.Skills[0] != "severity-triage" { + t.Errorf("definition skills: got %+v, want [severity-triage]", def.Skills) } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index 781d131200d..55b72a0ca1e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -457,12 +457,62 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB } // CreatePromptAgentAPIRequest converts a PromptAgent YAML definition into the +// mapHarness builds the `harness` block, or returns nil for a plain prompt +// agent so the field is omitted entirely. +// +// The harness is serialized as an object rather than the bare string it used to +// be, because that is the only shape with somewhere to put skills. The type +// value itself is passed through verbatim: azd does not maintain an allowlist +// of harness names, so a harness the service gains later needs no change here. +// +// Author-declared skill names are folded in alongside the ones the deploy graph +// published from the skills/ folder, and are matched by name so a manifest entry +// naming a folder-published skill does not produce a duplicate reference. +func mapHarness(promptAgent PromptAgent) *agent_api.ManagedAgentHarness { + harnessType := strings.TrimSpace(promptAgent.Harness) + if harnessType == "" { + return nil + } + + harness := &agent_api.ManagedAgentHarness{Type: harnessType} + seen := make(map[string]struct{}, len(promptAgent.HarnessSkills)) + for _, skill := range promptAgent.HarnessSkills { + name := strings.TrimSpace(skill.Name) + if name == "" { + continue + } + seen[name] = struct{}{} + harness.Skills = append(harness.Skills, agent_api.HarnessSkillReference{ + Name: name, + Version: strings.TrimSpace(skill.Version), + }) + } + for _, name := range promptAgent.Skills { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if _, dup := seen[name]; dup { + continue + } + seen[name] = struct{}{} + // No version: this name came from the manifest, not from a publish, so + // azd has nothing to pin it to and defers to the service's default. + harness.Skills = append(harness.Skills, agent_api.HarnessSkillReference{Name: name}) + } + return harness +} + // API CreateAgentRequest expected by the Foundry prompt-agent endpoint. // // Prompt agents are simpler than hosted agents — the customer only declares -// model + instructions (plus optional skills/policies). The platform manages -// the Brain+Hand sandbox, so no image/cpu/memory fields are required from the -// customer for the minimum case. +// model + instructions (plus optional skills/policies), so no image/cpu/memory +// fields are required from the customer for the minimum case. +// +// The agent's Harness is omitted entirely when empty: a harness-less prompt +// agent is run directly by Foundry, while a managed agent names its harness +// (e.g. "github-copilot") and the platform provisions a Brain+Hand sandbox for +// it. func CreatePromptAgentAPIRequest( promptAgent PromptAgent, buildConfig *AgentBuildConfig, @@ -473,6 +523,24 @@ func CreatePromptAgentAPIRequest( if strings.TrimSpace(promptAgent.Instructions) == "" { return nil, fmt.Errorf("prompt agent requires non-empty instructions") } + if err := promptAgent.ValidateHarness(); err != nil { + return nil, err + } + if err := promptAgent.ValidateHarnessFeatures(); err != nil { + return nil, err + } + if err := promptAgent.ValidateTools(); err != nil { + return nil, err + } + if err := promptAgent.ValidateHarnessFields(); err != nil { + return nil, err + } + if err := promptAgent.ValidateHarnessTools(); err != nil { + return nil, err + } + if err := promptAgent.ValidatePolicies(); err != nil { + return nil, err + } promptDef := agent_api.ManagedAgentDefinition{ AgentDefinition: agent_api.AgentDefinition{ @@ -480,11 +548,15 @@ func CreatePromptAgentAPIRequest( RaiConfig: mapRaiConfig(promptAgent.Policies), }, Model: promptAgent.Model, - Harness: agent_api.ManagedAgentHarnessGitHubCopilot, + Harness: mapHarness(promptAgent), Instructions: promptAgent.Instructions, } - if len(promptAgent.Skills) > 0 { + // Skills split on the harness. A harnessed agent carries them inside the + // harness block, where the service provisions them into the sandbox that + // runs them. A harness-less agent has no sandbox, so its skills stay on the + // definition-level field. + if promptDef.Harness == nil && len(promptAgent.Skills) > 0 { promptDef.Skills = append([]string(nil), promptAgent.Skills...) } @@ -502,6 +574,18 @@ func CreatePromptAgentAPIRequest( promptDef.StructuredInputs = promptAgent.StructuredInputs } + // Sampling and response-shape controls. Copied as pointers/any so an + // explicit zero (temperature: 0) survives as a zero rather than collapsing + // into "unset" and silently picking up the service default. + promptDef.Temperature = promptAgent.Temperature + promptDef.TopP = promptAgent.TopP + promptDef.Text = promptAgent.Text + promptDef.Reasoning = promptAgent.Reasoning + + // promptAgent.Memory is deliberately NOT copied here: the API has no memory + // field. The deploy engine provisions the store and injects a + // memory_search_preview entry into Tools, which the block above forwards. + // Build-time environment variables (if supplied) get carried into the // managed environment block so the Hand sandbox can read them. if buildConfig != nil && len(buildConfig.EnvironmentVariables) > 0 { diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go index ff80d499b1b..3c9fc7801f1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go @@ -411,6 +411,8 @@ func ValidateAgentDefinition(templateBytes []byte) error { errors = append(errors, fmt.Sprintf( "policies[%d] of type '%s' requires a policy name (rai_policy_name)", i, policy.Type)) + } else if err := ValidateRaiPolicyName(policy.RaiPolicyName); err != nil { + errors = append(errors, fmt.Sprintf("policies[%d]: %v", i, err)) } case "": errors = append(errors, fmt.Sprintf( @@ -444,13 +446,9 @@ func ValidateAgentDefinition(templateBytes []byte) error { if strings.TrimSpace(agent.Model) == "" { errors = append(errors, "template.model is required for prompt agents") } - // Instructions are intentionally NOT required inline here: - // prompt agents may supply them via a sibling instructions.md - // file (the deploy engine reads it when the inline value is - // empty). The deploy-time graph validation enforces that - // instructions are present from one source or the other, so a - // truly instruction-less agent is still rejected — just with a - // clearer, convention-aware message. + if strings.TrimSpace(agent.Instructions) == "" { + errors = append(errors, "template.instructions is required for prompt agents") + } for i, policy := range agent.Policies { switch policy.Type { case PolicyTypeRai: @@ -458,6 +456,8 @@ func ValidateAgentDefinition(templateBytes []byte) error { errors = append(errors, fmt.Sprintf( "policies[%d] of type '%s' requires a policy name (rai_policy_name)", i, policy.Type)) + } else if err := ValidateRaiPolicyName(policy.RaiPolicyName); err != nil { + errors = append(errors, fmt.Sprintf("policies[%d]: %v", i, err)) } case "": errors = append(errors, fmt.Sprintf( diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go new file mode 100644 index 00000000000..485629d4c1f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "fmt" + "strings" + + "azureaiagent/internal/pkg/agents/agent_api" +) + +// PromptFeature names an agent.yaml capability whose availability depends on +// the execution harness the prompt agent runs on. +// +// These are *capability* names, matching how the Foundry portal groups them. +// None of them is a field on the prompt-agent API — each is carried by an +// existing primitive, which is what declares() inspects: +// +// memory -> a memory store resource + a memory_search_preview tool entry +// guardrails -> policies: (rai_policy) -> the definition's rai_config +// knowledge -> grounding tools (file_search, azure_ai_search, bing_grounding, +// sharepoint_grounding_preview, ...), including the file_search +// entry azd synthesizes from the vector-assets/ folder +type PromptFeature string + +const ( + // PromptFeatureMemory is the `memory:` block — durable recall carried + // across invocations, backed by a Foundry memory store. + PromptFeatureMemory PromptFeature = "memory" + + // PromptFeatureGuardrails is the `policies:` block — safety and governance + // constraints applied to the agent's inputs and outputs. + PromptFeatureGuardrails PromptFeature = "guardrails" + + // PromptFeatureKnowledge is grounding: vector-assets/ plus any retrieval + // tool the agent declares. + PromptFeatureKnowledge PromptFeature = "knowledge" +) + +// promptFeatureOrder fixes the order features are reported in. Map iteration +// order is randomized, so errors built from harnessedPromptFeatures alone would +// name the same fields in a different order on each run. +var promptFeatureOrder = []PromptFeature{ + PromptFeatureMemory, + PromptFeatureGuardrails, + PromptFeatureKnowledge, +} + +// knowledgeToolTypes are the tool `type` values that ground an agent in an +// external corpus. This list only classifies a declared tool as "knowledge" for +// the harness gate below — it is NOT an allowlist. Tools are passed through to +// the API verbatim, so a type missing from this list still deploys; it simply +// is not counted as knowledge. +var knowledgeToolTypes = map[string]bool{ + "file_search": true, + "azure_ai_search": true, + "bing_grounding": true, + "bing_custom_search_preview": true, + "sharepoint_grounding_preview": true, + "fabric_dataagent_preview": true, + "fabric_iq_preview": true, + "work_iq_preview": true, +} + +// harnessedPromptFeatures records whether each capability is honored by a +// *harnessed* prompt agent — a managed agent that names a harness such as +// "github-copilot" and runs in a platform-provisioned sandbox. +// +// This map is the switch, and it follows the harness spec literally: a +// capability is enabled only where the spec says the harness honors it. +// +// - guardrails: enabled. The spec documents RAI policy attachment for +// harnessed agents. +// - knowledge: disabled. The spec puts grounding explicitly out of scope for +// the harness, which owns its own retrieval. +// - memory: disabled. The spec never describes memory for a harnessed agent, +// and the harness sandbox has no memory store to bind to. +// +// A disabled capability makes deploy fail fast with an actionable message +// naming the field, instead of silently dropping it after a successful-looking +// deploy. Flip an entry back to true once Foundry confirms the harness honors +// it. Harness-less prompt agents are unaffected in either state. +var harnessedPromptFeatures = map[PromptFeature]bool{ + PromptFeatureMemory: false, + PromptFeatureGuardrails: true, + PromptFeatureKnowledge: false, +} + +// declares reports whether the agent configures the given capability, by +// inspecting the primitive that actually carries it. +func (p PromptAgent) declares(feature PromptFeature) bool { + switch feature { + case PromptFeatureMemory: + return p.Memory != nil + case PromptFeatureGuardrails: + for _, policy := range p.Policies { + if policy.Type == PolicyTypeRai && strings.TrimSpace(policy.RaiPolicyName) != "" { + return true + } + } + return false + case PromptFeatureKnowledge: + for _, raw := range p.Tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if knowledgeToolTypes[fmt.Sprintf("%v", tool["type"])] { + return true + } + } + return false + default: + return false + } +} + +// UnsupportedHarnessFeatures lists the capabilities this agent configures that +// its harness cannot honor, in a stable order. It returns nil for a harness-less +// prompt agent, which supports all of them. +func (p PromptAgent) UnsupportedHarnessFeatures() []PromptFeature { + if strings.TrimSpace(p.Harness) == "" { + return nil + } + + var unsupported []PromptFeature + for _, feature := range promptFeatureOrder { + if harnessedPromptFeatures[feature] { + continue + } + if p.declares(feature) { + unsupported = append(unsupported, feature) + } + } + return unsupported +} + +// ValidateHarnessFeatures rejects capabilities the agent's harness cannot +// honor. Failing loudly is deliberate: the alternative is publishing an agent +// that looks correctly configured but ignores the capability at runtime, which +// is far harder to diagnose than a deploy-time error. +func (p PromptAgent) ValidateHarnessFeatures() error { + unsupported := p.UnsupportedHarnessFeatures() + if len(unsupported) == 0 { + return nil + } + + names := make([]string, 0, len(unsupported)) + for _, feature := range unsupported { + names = append(names, string(feature)) + } + + return fmt.Errorf( + "agent.yaml configures %s, which the %q harness does not support yet", + strings.Join(names, ", "), strings.TrimSpace(p.Harness), + ) +} + +// raiPolicyIDPrefix and raiPolicyIDSegment are the two fixed parts of a RAI +// policy's ARM resource ID. +const ( + raiPolicyIDPrefix = "/subscriptions/" + raiPolicyIDSegment = "/raiPolicies/" +) + +// ValidateRaiPolicyName rejects a policy value that is not a full ARM resource +// ID. +// +// The service reports a bare policy name as "invalid or does not exist", which +// reads like a missing resource and sends authors hunting for a policy that is +// in fact present on their account. The real cause is the shape of the value, +// so name that instead of letting the deploy fail on a misleading message. +func ValidateRaiPolicyName(name string) error { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return nil + } + if strings.HasPrefix(trimmed, raiPolicyIDPrefix) && strings.Contains(trimmed, raiPolicyIDSegment) { + return nil + } + return fmt.Errorf( + "rai_policy_name %q must be the policy's full ARM resource ID, not its short name; "+ + "expected the form /subscriptions//resourceGroups//providers/"+ + "Microsoft.CognitiveServices/accounts//raiPolicies/", + trimmed, + ) +} + +// ValidatePolicies rejects policy entries the service will refuse. +func (p PromptAgent) ValidatePolicies() error { + for i, policy := range p.Policies { + if policy.Type != PolicyTypeRai { + continue + } + if err := ValidateRaiPolicyName(policy.RaiPolicyName); err != nil { + return fmt.Errorf("policies[%d]: %w", i, err) + } + } + return nil +} + +// removedHarnesses maps a harness spelling that is no longer accepted to the +// spelling that replaced it. +// +// Unlike tool types, an unrecognized harness is *not* rejected: a harness azd +// has never heard of may simply be newer than this build, and hard-failing +// would make every new Foundry harness a breaking change in azd. Only spellings +// known to be wrong are refused, and each one names its replacement. +var removedHarnesses = map[string]string{ + agent_api.ManagedAgentHarnessGitHubCopilotRemoved: agent_api.ManagedAgentHarnessGitHubCopilot, +} + +// ValidateHarness rejects harness spellings that have been replaced. The value +// is passed to the service verbatim, and the service ignores a harness it does +// not recognize rather than erroring — so an outdated spelling would otherwise +// publish a plain prompt agent while the manifest claims a managed one. +func (p PromptAgent) ValidateHarness() error { + harness := strings.TrimSpace(p.Harness) + if harness == "" { + return nil + } + if replacement, removed := removedHarnesses[harness]; removed { + return fmt.Errorf( + "harness %q is no longer accepted; use %q instead", + harness, replacement, + ) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go new file mode 100644 index 00000000000..23ca172b2cf --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go @@ -0,0 +1,390 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "encoding/json" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/stretchr/testify/require" + yaml "go.yaml.in/yaml/v3" +) + +// testRaiPolicyID is a syntactically valid RAI policy ARM resource ID. The +// service (and now azd) rejects a bare policy name, so fixtures have to carry +// the full ID. +const testRaiPolicyID = "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/acct/raiPolicies/strict" + +// TestPromptAgent_MemoryRoundTrip verifies the memory block decodes into its +// typed shape. Memory is not passthrough — azd has to read the store name and +// models to provision the store — so the field names must bind, not just parse. +func TestPromptAgent_MemoryRoundTrip(t *testing.T) { + t.Parallel() + + content := []byte(` +kind: prompt +name: full-featured +model: gpt-4.1-mini +instructions: Be helpful. +memory: + store: support-memory + chat_model: gpt-4.1-mini + embedding_model: text-embedding-3-small + scope: user_123 + update_delay: 300 + max_memories: 5 + options: + user_profile_enabled: true + chat_summary_enabled: false +`) + + var agent PromptAgent + require.NoError(t, yaml.Unmarshal(content, &agent)) + + require.NotNil(t, agent.Memory) + require.Equal(t, "support-memory", agent.Memory.Store) + require.Equal(t, "gpt-4.1-mini", agent.Memory.ChatModel) + require.Equal(t, "text-embedding-3-small", agent.Memory.EmbeddingModel) + require.Equal(t, "user_123", agent.Memory.Scope) + require.Equal(t, 300, *agent.Memory.UpdateDelay) + require.Equal(t, 5, *agent.Memory.MaxMemories) + + require.NotNil(t, agent.Memory.Options) + require.True(t, *agent.Memory.Options.UserProfileEnabled) + // Explicit false must survive as false rather than collapsing into "unset", + // which is why the option toggles are pointers. + require.False(t, *agent.Memory.Options.ChatSummaryEnabled) + require.Nil(t, agent.Memory.Options.ProceduralMemoryEnabled) +} + +// TestPromptAgent_Declares verifies each capability is detected from the +// primitive that actually carries it, since none of them is a field of its own. +func TestPromptAgent_Declares(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + agent PromptAgent + feature PromptFeature + want bool + }{ + { + name: "memory block", + agent: PromptAgent{Memory: &PromptMemory{Store: "s"}}, + feature: PromptFeatureMemory, + want: true, + }, + { + name: "no memory block", + agent: PromptAgent{}, + feature: PromptFeatureMemory, + want: false, + }, + { + name: "rai policy is a guardrail", + agent: PromptAgent{ + Policies: []Policy{{Type: PolicyTypeRai, RaiPolicyName: testRaiPolicyID}}, + }, + feature: PromptFeatureGuardrails, + want: true, + }, + { + // A policy entry with no name maps to no rai_config, so it configures + // nothing and must not read as a guardrail. + name: "rai policy without a name is not a guardrail", + agent: PromptAgent{Policies: []Policy{{Type: PolicyTypeRai}}}, + feature: PromptFeatureGuardrails, + want: false, + }, + { + name: "file_search is knowledge", + agent: PromptAgent{ + Tools: []any{map[string]any{"type": "file_search"}}, + }, + feature: PromptFeatureKnowledge, + want: true, + }, + { + name: "azure_ai_search is knowledge", + agent: PromptAgent{ + Tools: []any{map[string]any{"type": "azure_ai_search"}}, + }, + feature: PromptFeatureKnowledge, + want: true, + }, + { + name: "a non-grounding tool is not knowledge", + agent: PromptAgent{ + Tools: []any{map[string]any{"type": "code_interpreter"}}, + }, + feature: PromptFeatureKnowledge, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, tc.agent.declares(tc.feature)) + }) + } +} + +// TestPromptAgent_ValidateHarness pins the removed-value check. The list is +// deliberately a rejection list rather than an allowlist: a harness the service +// adds after this build shipped must keep working, so only spellings we know +// were withdrawn are refused. +func TestPromptAgent_ValidateHarness(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + harness string + wantErrPart string + }{ + {name: "absent harness is a plain prompt agent", harness: ""}, + {name: "whitespace is treated as absent", harness: " "}, + {name: "current spelling is accepted", harness: "github-copilot"}, + { + name: "removed spelling names its replacement", + harness: "ghcp", + wantErrPart: "github-copilot", + }, + { + name: "unknown harness is left to the service", + harness: "some-future-harness", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := PromptAgent{Harness: tc.harness}.ValidateHarness() + if tc.wantErrPart == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErrPart) + }) + } +} + +// TestValidateHarnessFeatures verifies the harness capability switch. Only +// guardrails is enabled for harnessed agents -- the harness spec documents RAI +// policy attachment but puts grounding out of scope and never describes memory +// -- so a harnessed agent declaring memory or knowledge is rejected up front +// rather than deploying with the capability silently dropped. The test pins +// each entry so flipping one in harnessedPromptFeatures is a deliberate, +// visible change. +func TestValidateHarnessFeatures(t *testing.T) { + t.Parallel() + + fullyFeatured := PromptAgent{ + Memory: &PromptMemory{Store: "s"}, + Policies: []Policy{{Type: PolicyTypeRai, RaiPolicyName: testRaiPolicyID}}, + Tools: []any{map[string]any{"type": "file_search"}}, + } + + tests := []struct { + name string + harness string + agent PromptAgent + wantRejected []PromptFeature + }{ + { + name: "harness-less agent accepts every capability", + agent: fullyFeatured, + }, + { + name: "harnessed agent without capabilities is fine", + harness: "github-copilot", + agent: PromptAgent{}, + }, + { + name: "harnessed agent accepts guardrails", + harness: "github-copilot", + agent: PromptAgent{ + Policies: []Policy{{Type: PolicyTypeRai, RaiPolicyName: testRaiPolicyID}}, + }, + }, + { + name: "harnessed agent rejects memory", + harness: "github-copilot", + agent: PromptAgent{Memory: &PromptMemory{Store: "s"}}, + wantRejected: []PromptFeature{PromptFeatureMemory}, + }, + { + name: "harnessed agent rejects knowledge", + harness: "github-copilot", + agent: PromptAgent{Tools: []any{map[string]any{"type": "file_search"}}}, + wantRejected: []PromptFeature{PromptFeatureKnowledge}, + }, + { + name: "harnessed agent reports memory and knowledge together", + harness: "github-copilot", + agent: fullyFeatured, + wantRejected: []PromptFeature{PromptFeatureMemory, PromptFeatureKnowledge}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + agent := tc.agent + agent.Harness = tc.harness + + if len(tc.wantRejected) == 0 { + require.NoError(t, agent.ValidateHarnessFeatures()) + require.Empty(t, agent.UnsupportedHarnessFeatures()) + return + } + require.Error(t, agent.ValidateHarnessFeatures()) + require.Equal(t, tc.wantRejected, agent.UnsupportedHarnessFeatures()) + }) + } +} + +// TestUnsupportedHarnessFeatures_ReportingOrder verifies that when a capability +// is disabled the report is deterministic and harness-less agents stay exempt. +// It drives the switch directly rather than relying on the shipped values, so +// the ordering guarantee holds whichever entries are enabled. +func TestUnsupportedHarnessFeatures_ReportingOrder(t *testing.T) { + // Not parallel: this mutates the package-level switch. + original := harnessedPromptFeatures + t.Cleanup(func() { harnessedPromptFeatures = original }) + + harnessedPromptFeatures = map[PromptFeature]bool{ + PromptFeatureMemory: false, + PromptFeatureGuardrails: false, + PromptFeatureKnowledge: false, + } + + agent := PromptAgent{ + Memory: &PromptMemory{Store: "s"}, + Policies: []Policy{{Type: PolicyTypeRai, RaiPolicyName: testRaiPolicyID}}, + Tools: []any{map[string]any{"type": "file_search"}}, + } + + // A harness-less agent is never gated, whatever the switch says. + require.NoError(t, agent.ValidateHarnessFeatures()) + + agent.Harness = "github-copilot" + err := agent.ValidateHarnessFeatures() + require.Error(t, err) + require.Contains(t, err.Error(), "github-copilot") + + names := make([]string, 0, 3) + for _, feature := range agent.UnsupportedHarnessFeatures() { + names = append(names, string(feature)) + } + // Order is asserted, not just membership: promptFeatureOrder exists so + // repeated runs cannot produce differently-ordered messages. + require.Equal(t, []string{"memory", "guardrails", "knowledge"}, names) +} + +// TestCreatePromptAgentAPIRequest_FeatureCarriers verifies each capability +// reaches the payload through its real carrier — and, critically, that `memory` +// is NOT emitted as a top-level field. The prompt-agent API defines no such +// field, so sending one would be silently dropped by the service and the agent +// would deploy "successfully" with no memory at all. +func TestCreatePromptAgentAPIRequest_FeatureCarriers(t *testing.T) { + t.Parallel() + + base := PromptAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindPrompt, Name: "a"}, + Model: "gpt-4.1-mini", + Instructions: "Be helpful.", + Memory: &PromptMemory{Store: "s", ChatModel: "c", EmbeddingModel: "e"}, + Policies: []Policy{{Type: PolicyTypeRai, RaiPolicyName: testRaiPolicyID}}, + Tools: []any{ + map[string]any{"type": "azure_ai_search", "index_name": "handbook"}, + }, + } + + req, err := CreatePromptAgentAPIRequest(base, nil) + require.NoError(t, err) + + def, ok := req.Definition.(agent_api.ManagedAgentDefinition) + require.True(t, ok, "definition: got %T", req.Definition) + + // Guardrails ride on rai_config. + require.NotNil(t, def.RaiConfig) + require.Equal(t, testRaiPolicyID, def.RaiConfig.RaiPolicyName) + + // Knowledge rides on tools, forwarded verbatim. + require.Equal(t, base.Tools, def.Tools) + + // Memory must not leak into the payload as a field of its own. The deploy + // graph injects a memory_search_preview tool instead. + encoded, err := json.Marshal(def) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(encoded, &payload)) + require.NotContains(t, payload, "memory") + require.NotContains(t, payload, "guardrails") + require.NotContains(t, payload, "knowledge") +} + +// TestValidateRaiPolicyName covers the bare-name mistake that the service +// reports as "invalid or does not exist" — a message that sends authors looking +// for a missing policy when the value's shape is the actual problem. +func TestValidateRaiPolicyName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + policy string + wantErr bool + }{ + {name: "empty is left to the required-field check", policy: "", wantErr: false}, + {name: "whitespace only", policy: " ", wantErr: false}, + {name: "full arm id", policy: testRaiPolicyID, wantErr: false}, + {name: "short but well formed arm id", policy: "/subscriptions/s/raiPolicies/p", wantErr: false}, + {name: "surrounding whitespace tolerated", policy: " " + testRaiPolicyID + " ", wantErr: false}, + {name: "bare built-in name", policy: "Microsoft.DefaultV2", wantErr: true}, + {name: "bare custom name", policy: "strict", wantErr: true}, + {name: "missing raiPolicies segment", policy: "/subscriptions/s/resourceGroups/rg", wantErr: true}, + {name: "missing subscriptions prefix", policy: "/resourceGroups/rg/raiPolicies/p", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateRaiPolicyName(tt.policy) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), "full ARM resource ID") + return + } + require.NoError(t, err) + }) + } +} + +// TestPromptAgent_ValidatePolicies checks the per-entry wiring: the index is +// reported, and a policy of another type is not held to the RAI rule. +func TestPromptAgent_ValidatePolicies(t *testing.T) { + t.Parallel() + + agent := PromptAgent{ + Policies: []Policy{ + {Type: PolicyTypeRai, RaiPolicyName: testRaiPolicyID}, + {Type: PolicyTypeRai, RaiPolicyName: "Microsoft.DefaultV2"}, + }, + } + err := agent.ValidatePolicies() + require.Error(t, err) + require.Contains(t, err.Error(), "policies[1]") + + other := PromptAgent{Policies: []Policy{{Type: "other", RaiPolicyName: "strict"}}} + require.NoError(t, other.ValidatePolicies()) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate.go new file mode 100644 index 00000000000..ae1e22c7d65 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate.go @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "fmt" + "slices" + "sort" + "strings" +) + +// The gates in this file apply *only* to harnessed prompt agents. A harness-less +// prompt agent keeps every field and tool type it accepts today — the harness +// spec constrains the sandboxed execution environment, not the base agent. +// +// These are rejections rather than warnings because the spec is explicit that +// the service does not silently ignore them: a manifest carrying one of these +// fields fails at the API. Catching it here turns a late, opaque service error +// into a deploy-time message that names the offending key. + +// harnessRejectedToolTypes are tool `type` values a harnessed prompt agent may +// not declare. +// +// A harness runs its tools through a platform-managed toolbox, and these types +// have no toolbox representation: they either need a customer-supplied +// execution target the sandbox does not expose (function, azure_function, +// custom, openapi-adjacent shells) or duplicate a capability the harness +// already provides natively (shell, local_shell, computer, apply_patch). +// +// Unlike knownPromptToolTypes this *is* an authoritative list, taken from the +// spec's rejection table. A type absent from it is allowed through, so tool +// types newer than this build still deploy. +var harnessRejectedToolTypes = map[string]struct{}{ + "apply_patch": {}, + "azure_function": {}, + "bing_grounding": {}, + "capture_structured_outputs": {}, + "computer": {}, + "custom": {}, + "function": {}, + "image_generation": {}, + "local_shell": {}, + "namespace": {}, + "programmatic_tool_calling": {}, + "shell": {}, +} + +// reasoningEffortKey is the single `reasoning` property a harness honors. +const reasoningEffortKey = "effort" + +// harnessed reports whether the agent names an execution harness. +func (p PromptAgent) harnessed() bool { + return strings.TrimSpace(p.Harness) != "" +} + +// ValidateHarnessFields rejects sampling and output-shaping fields a harnessed +// prompt agent may not set. +// +// The harness owns decoding: it supplies its own sampling parameters and its +// own response format, so an author-supplied temperature, top_p, tool_choice or +// text block would be overridden rather than applied. +func (p PromptAgent) ValidateHarnessFields() error { + if !p.harnessed() { + return nil + } + + var rejected []string + if p.Temperature != nil { + rejected = append(rejected, "temperature") + } + if p.TopP != nil { + rejected = append(rejected, "top_p") + } + if p.ToolChoice != nil { + rejected = append(rejected, "tool_choice") + } + if p.Text != nil { + rejected = append(rejected, "text") + } + + if len(rejected) > 0 { + return fmt.Errorf( + "agent.yaml sets %s, which the %q harness does not accept because it controls "+ + "sampling and response format itself", + strings.Join(rejected, ", "), strings.TrimSpace(p.Harness), + ) + } + + return p.validateHarnessReasoning() +} + +// validateHarnessReasoning rejects `reasoning` properties other than `effort`. +// +// A non-mapping `reasoning` value is left alone: it is either absent or already +// malformed, and reporting a shape error here would mask the clearer one the +// schema check produces. +func (p PromptAgent) validateHarnessReasoning() error { + reasoning, ok := p.Reasoning.(map[string]any) + if !ok { + return nil + } + + var extra []string + for key := range reasoning { + if key != reasoningEffortKey { + extra = append(extra, key) + } + } + if len(extra) == 0 { + return nil + } + sort.Strings(extra) + + return fmt.Errorf( + "agent.yaml sets reasoning.%s, which the %q harness does not accept; "+ + "only reasoning.%s is supported", + strings.Join(extra, ", reasoning."), strings.TrimSpace(p.Harness), reasoningEffortKey, + ) +} + +// ValidateHarnessTools rejects declared tool types a harnessed prompt agent +// cannot run. Structurally malformed entries are skipped — ValidateTools +// reports those, with a better message. +func (p PromptAgent) ValidateHarnessTools() error { + if !p.harnessed() { + return nil + } + + var rejected []string + for _, raw := range p.Tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + toolType, err := toolTypeOf(tool) + if err != nil { + continue + } + if _, bad := harnessRejectedToolTypes[toolType]; !bad { + continue + } + if !slices.Contains(rejected, toolType) { + rejected = append(rejected, toolType) + } + } + + if len(rejected) == 0 { + return nil + } + sort.Strings(rejected) + + return fmt.Errorf( + "agent.yaml declares tool %s, which the %q harness does not accept because it runs "+ + "tools through a platform-managed toolbox", + strings.Join(rejected, ", "), strings.TrimSpace(p.Harness), + ) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate_test.go new file mode 100644 index 00000000000..d1e28bf7618 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate_test.go @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestValidateHarnessFields covers the sampling and output-shaping fields a +// harness controls itself. The harness-less cases matter most: they are the +// guarantee that this gate never narrows what a plain prompt agent accepts. +func TestValidateHarnessFields(t *testing.T) { + t.Parallel() + + temperature := 0.7 + topP := 0.9 + + cases := []struct { + name string + agent PromptAgent + wantErr bool + wantMessage string + }{ + { + name: "harness-less agent may set every field", + agent: PromptAgent{Temperature: &temperature, TopP: &topP, ToolChoice: "auto", Text: map[string]any{}}, + }, + { + name: "harnessed agent with no sampling fields", + agent: PromptAgent{Harness: "github-copilot"}, + }, + { + name: "harnessed agent rejects temperature", + agent: PromptAgent{Harness: "github-copilot", Temperature: &temperature}, + wantErr: true, + wantMessage: "temperature", + }, + { + name: "harnessed agent rejects top_p", + agent: PromptAgent{Harness: "github-copilot", TopP: &topP}, + wantErr: true, + wantMessage: "top_p", + }, + { + name: "harnessed agent rejects tool_choice", + agent: PromptAgent{Harness: "github-copilot", ToolChoice: "auto"}, + wantErr: true, + wantMessage: "tool_choice", + }, + { + name: "harnessed agent rejects text", + agent: PromptAgent{Harness: "github-copilot", Text: map[string]any{"format": "json"}}, + wantErr: true, + wantMessage: "text", + }, + { + name: "all rejected fields are reported together in a stable order", + agent: PromptAgent{ + Harness: "github-copilot", + Temperature: &temperature, + TopP: &topP, + ToolChoice: "auto", + Text: map[string]any{}, + }, + wantErr: true, + wantMessage: "temperature, top_p, tool_choice, text", + }, + { + name: "harnessed agent accepts reasoning.effort", + agent: PromptAgent{ + Harness: "github-copilot", + Reasoning: map[string]any{"effort": "medium"}, + }, + }, + { + name: "harnessed agent rejects other reasoning properties", + agent: PromptAgent{ + Harness: "github-copilot", + Reasoning: map[string]any{"effort": "medium", "summary": "detailed"}, + }, + wantErr: true, + wantMessage: "reasoning.summary", + }, + { + name: "non-mapping reasoning is left to the schema check", + agent: PromptAgent{ + Harness: "github-copilot", + Reasoning: "medium", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.agent.ValidateHarnessFields() + if !tc.wantErr { + require.NoError(t, err) + return + } + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantMessage) + }) + } +} + +// TestValidateHarnessTools covers the tool types that have no representation in +// the platform-managed toolbox a harness dispatches through. +func TestValidateHarnessTools(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + agent PromptAgent + wantErr bool + wantMessage string + }{ + { + name: "harness-less agent may declare a function tool", + agent: PromptAgent{ + Tools: []any{map[string]any{"type": "function", "name": "get_order_status"}}, + }, + }, + { + name: "harness-less agent may declare a shell tool", + agent: PromptAgent{ + Tools: []any{map[string]any{"type": "shell"}}, + }, + }, + { + name: "harnessed agent accepts toolbox-backed tools", + agent: PromptAgent{ + Harness: "github-copilot", + Tools: []any{ + map[string]any{"type": "code_interpreter"}, + map[string]any{"type": "file_search"}, + map[string]any{"type": "mcp"}, + }, + }, + }, + { + name: "harnessed agent rejects a function tool", + agent: PromptAgent{ + Harness: "github-copilot", + Tools: []any{map[string]any{"type": "function", "name": "get_order_status"}}, + }, + wantErr: true, + wantMessage: "function", + }, + { + name: "harnessed agent rejects bing_grounding", + agent: PromptAgent{ + Harness: "github-copilot", + Tools: []any{map[string]any{"type": "bing_grounding"}}, + }, + wantErr: true, + wantMessage: "bing_grounding", + }, + { + name: "rejected tool types are deduplicated and sorted", + agent: PromptAgent{ + Harness: "github-copilot", + Tools: []any{ + map[string]any{"type": "shell"}, + map[string]any{"type": "function", "name": "a"}, + map[string]any{"type": "function", "name": "b"}, + }, + }, + wantErr: true, + wantMessage: "function, shell", + }, + { + name: "an unrecognized tool type still deploys", + agent: PromptAgent{ + Harness: "github-copilot", + Tools: []any{map[string]any{"type": "some_future_tool"}}, + }, + }, + { + name: "a malformed entry is left to ValidateTools", + agent: PromptAgent{ + Harness: "github-copilot", + Tools: []any{"not-a-mapping"}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.agent.ValidateHarnessTools() + if !tc.wantErr { + require.NoError(t, err) + return + } + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantMessage) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go index 6192ee064d0..e20c9682369 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go @@ -27,7 +27,6 @@ connections: authType: ApiKey credentials: key: ${SEARCH_API_KEY} - provision: true `) var promptDef PromptAgent @@ -47,7 +46,7 @@ connections: } second := promptDef.Connections[1] - if second.AuthType != "ApiKey" || !second.Provision { + if second.AuthType != "ApiKey" { t.Errorf("second connection: got %+v", second) } if second.Credentials["key"] != "${SEARCH_API_KEY}" { diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_tools.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_tools.go new file mode 100644 index 00000000000..7a42f184e59 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_tools.go @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "fmt" + "slices" + "sort" + "strings" +) + +// knownPromptToolTypes is the set of tool `type` discriminators the Foundry +// prompt-agent API defines, mirroring the service's ToolType enum. +// +// This is a **recognition list, not an allowlist**. `tools:` is passed through +// verbatim precisely so authors can use a tool type that ships before azd knows +// about it, and hard-failing on an unrecognized type would make every new +// service tool a breaking change in azd. An unrecognized type is therefore +// reported as a warning and still deployed. +// +// It exists because the failure mode without it is the worst kind: the service +// ignores tool entries whose type it does not recognize, so a typo deploys +// "successfully" and produces an agent that silently lacks the capability. +var knownPromptToolTypes = map[string]struct{}{ + "a2a_preview": {}, + "apply_patch": {}, + "azure_ai_search": {}, + "azure_function": {}, + "bing_custom_search_preview": {}, + "bing_grounding": {}, + "browser_automation_preview": {}, + "capture_structured_outputs": {}, + "code_interpreter": {}, + "computer": {}, + "computer_use_preview": {}, + "custom": {}, + "fabric_dataagent_preview": {}, + "fabric_iq_preview": {}, + "file_search": {}, + "function": {}, + "image_generation": {}, + "local_shell": {}, + "mcp": {}, + "memory_search_preview": {}, + "namespace": {}, + "openapi": {}, + "reminder_preview": {}, + "shell": {}, + "sharepoint_grounding_preview": {}, + "tool_search": {}, + "toolbox_search": {}, + "toolbox_search_preview": {}, + "web_iq_preview": {}, + "web_search": {}, + "web_search_preview": {}, + "work_iq_preview": {}, +} + +// removedPromptToolTypes maps tool types the API used to define onto the type +// that replaced them. These are called out separately from merely-unrecognized +// types because the author almost certainly meant the replacement, and because +// the two spellings are close enough to be mistaken for each other. +var removedPromptToolTypes = map[string]string{ + "memory_search": "memory_search_preview", +} + +// ValidateTools rejects entries in `tools:` that are structurally malformed. +// +// Only unambiguous errors are raised here: an entry that is not a mapping, or +// one with no usable `type`. Both are inert on the wire — the service cannot +// dispatch a tool it cannot identify — so accepting them would publish an agent +// missing a capability its manifest claims. Unrecognized (as opposed to +// missing) types are deliberately not an error; see UnrecognizedToolTypes. +func (p *PromptAgent) ValidateTools() error { + for i, raw := range p.Tools { + tool, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf( + "tools[%d] must be a mapping with a 'type' key, got %T", i, raw) + } + + toolType, err := toolTypeOf(tool) + if err != nil { + return fmt.Errorf("tools[%d]: %w", i, err) + } + + if replacement, removed := removedPromptToolTypes[toolType]; removed { + return fmt.Errorf( + "tools[%d] uses tool type %q, which the API no longer defines; use %q instead", + i, toolType, replacement) + } + } + return nil +} + +// UnrecognizedToolTypes returns the declared tool types azd does not recognize, +// sorted and deduplicated. Callers surface these as warnings during deploy. +// +// A non-empty result is usually a typo, but may equally be a tool type newer +// than this build of azd — which is why it does not fail the deploy. +func (p *PromptAgent) UnrecognizedToolTypes() []string { + var unrecognized []string + + for _, raw := range p.Tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + toolType, err := toolTypeOf(tool) + if err != nil { + continue + } + if _, known := knownPromptToolTypes[toolType]; known { + continue + } + if !slices.Contains(unrecognized, toolType) { + unrecognized = append(unrecognized, toolType) + } + } + + sort.Strings(unrecognized) + return unrecognized +} + +// toolTypeOf extracts the `type` discriminator from a decoded tool entry. +func toolTypeOf(tool map[string]any) (string, error) { + raw, present := tool["type"] + if !present { + return "", fmt.Errorf("tool entry is missing a 'type' key") + } + + // YAML decodes an unquoted scalar to its natural Go type, so a mistake like + // `type: 42` arrives as an int rather than a string. Reject it by shape + // instead of stringifying, which would turn the mistake into a plausible + // looking tool type. + toolType, ok := raw.(string) + if !ok { + return "", fmt.Errorf("tool 'type' must be a string, got %T", raw) + } + + toolType = strings.TrimSpace(toolType) + if toolType == "" { + return "", fmt.Errorf("tool 'type' must not be empty") + } + + return toolType, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_tools_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_tools_test.go new file mode 100644 index 00000000000..6250baf9cb7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_tools_test.go @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" +) + +func TestPromptAgent_ValidateTools(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tools []any + wantErr string + }{ + { + name: "no tools", + tools: nil, + }, + { + name: "known types", + tools: []any{ + map[string]any{"type": "file_search"}, + map[string]any{"type": "memory_search_preview", "memory_store_name": "m"}, + }, + }, + { + // Unrecognized is not an error: the type may simply be newer than + // this build of azd. + name: "unrecognized type is allowed through", + tools: []any{map[string]any{"type": "brand_new_tool_preview"}}, + }, + { + name: "entry is not a mapping", + tools: []any{"file_search"}, + wantErr: "tools[0] must be a mapping with a 'type' key, got string", + }, + { + name: "entry has no type", + tools: []any{map[string]any{"server_label": "toolbox"}}, + wantErr: "tools[0]: tool entry is missing a 'type' key", + }, + { + name: "type is not a string", + tools: []any{map[string]any{"type": 42}}, + wantErr: "tools[0]: tool 'type' must be a string, got int", + }, + { + name: "type is blank", + tools: []any{map[string]any{"type": " "}}, + wantErr: "tools[0]: tool 'type' must not be empty", + }, + { + name: "removed type names its replacement", + tools: []any{map[string]any{"type": "memory_search"}}, + wantErr: `tools[0] uses tool type "memory_search", which the API no longer defines; ` + + `use "memory_search_preview" instead`, + }, + { + name: "error names the offending index, not the first", + tools: []any{ + map[string]any{"type": "file_search"}, + map[string]any{"type": "code_interpreter"}, + map[string]any{"no_type": true}, + }, + wantErr: "tools[2]: tool entry is missing a 'type' key", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + agent := &PromptAgent{Tools: test.tools} + err := agent.ValidateTools() + + if test.wantErr == "" { + require.NoError(t, err) + return + } + require.EqualError(t, err, test.wantErr) + }) + } +} + +func TestPromptAgent_UnrecognizedToolTypes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tools []any + want []string + }{ + { + name: "all recognized", + tools: []any{map[string]any{"type": "azure_ai_search"}, map[string]any{"type": "mcp"}}, + want: nil, + }, + { + name: "typo is reported", + tools: []any{map[string]any{"type": "file_serach"}}, + want: []string{"file_serach"}, + }, + { + name: "sorted and deduplicated", + tools: []any{ + map[string]any{"type": "zzz_tool"}, + map[string]any{"type": "aaa_tool"}, + map[string]any{"type": "zzz_tool"}, + }, + want: []string{"aaa_tool", "zzz_tool"}, + }, + { + // Malformed entries are ValidateTools' job; reporting them here too + // would double up on the same mistake. + name: "malformed entries are skipped", + tools: []any{"not-a-map", map[string]any{"type": 7}, map[string]any{}}, + want: nil, + }, + { + name: "every preview tool type is recognized", + tools: []any{map[string]any{"type": "sharepoint_grounding_preview"}, map[string]any{"type": "a2a_preview"}}, + want: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + agent := &PromptAgent{Tools: test.tools} + require.Equal(t, test.want, agent.UnrecognizedToolTypes()) + }) + } +} + +// TestPromptAgent_InjectedToolTypesAreRecognized guards against azd warning +// about a tool it injected itself. +func TestPromptAgent_InjectedToolTypesAreRecognized(t *testing.T) { + t.Parallel() + + for _, injected := range []string{"file_search", "mcp", "memory_search_preview"} { + _, known := knownPromptToolTypes[injected] + require.True(t, known, "azd injects %q; it must be in the recognized set", injected) + } +} + +// TestPromptAgent_SamplingFieldsRoundTrip covers the four API definition fields +// that previously had no agent.yaml binding. +func TestPromptAgent_SamplingFieldsRoundTrip(t *testing.T) { + t.Parallel() + + content := []byte(` +kind: prompt +name: sampling-agent +model: gpt-4.1-mini +instructions: You are helpful. +temperature: 0 +top_p: 0.95 +text: + format: + type: json_schema +reasoning: + effort: high +`) + + var agent PromptAgent + require.NoError(t, yaml.Unmarshal(content, &agent)) + + // A pointer, so an explicit 0 is distinguishable from unset. Collapsing the + // two would silently substitute the service default for "be deterministic". + require.NotNil(t, agent.Temperature) + require.Equal(t, 0.0, *agent.Temperature) + + require.NotNil(t, agent.TopP) + require.Equal(t, 0.95, *agent.TopP) + + require.NotNil(t, agent.Text) + require.NotNil(t, agent.Reasoning) + + request, err := CreatePromptAgentAPIRequest(agent, nil) + require.NoError(t, err) + + definition, ok := request.Definition.(agent_api.ManagedAgentDefinition) + require.True(t, ok, "definition type changed; update this assertion") + require.NotNil(t, definition.Temperature) + require.Equal(t, 0.0, *definition.Temperature) + require.NotNil(t, definition.TopP) + require.NotNil(t, definition.Text) + require.NotNil(t, definition.Reasoning) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/samples_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/samples_test.go new file mode 100644 index 00000000000..918eb86865c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/samples_test.go @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" +) + +// samplesDir is the authored-examples tree at the extension root. +const samplesDir = "../../../../samples" + +// TestSamples_Parse keeps the hand-authored samples honest. +// +// The samples exist to be copied, so a key that silently fails to bind is worse +// than a broken build: it teaches the wrong schema. Decoding with KnownFields +// turns any typo, stale key, or invented field in samples/**/agent.yaml into a +// test failure here rather than into a deployed agent that quietly ignores it. +func TestSamples_Parse(t *testing.T) { + t.Parallel() + + manifests := findSampleManifests(t) + require.NotEmpty(t, manifests, "no sample agent.yaml files found under %s", samplesDir) + + for _, manifest := range manifests { + name, err := filepath.Rel(samplesDir, manifest) + require.NoError(t, err) + + t.Run(filepath.ToSlash(name), func(t *testing.T) { + t.Parallel() + + content, err := os.ReadFile(manifest) + require.NoError(t, err) + + decoder := yaml.NewDecoder(strings.NewReader(string(content))) + decoder.KnownFields(true) + + var agent PromptAgent + require.NoError(t, decoder.Decode(&agent), "sample declares a key PromptAgent does not bind") + + require.Equal(t, AgentKindPrompt, agent.Kind, "samples are all prompt agents") + require.NotEmpty(t, agent.Name) + require.NotEmpty(t, agent.Model) + + // Instructions are declared inline, matching the prompt-agent API + // schema. A sample that drops them would teach a shape the service + // rejects. + require.NotEmpty(t, agent.Instructions, "samples declare instructions inline") + + assertSampleMemoryIsDeployable(t, agent.Memory) + }) + } +} + +// TestSamples_BuildAPIRequest runs the samples through the same mapping the +// deploy path uses, so a sample cannot pass parsing yet fail at deploy time. +func TestSamples_BuildAPIRequest(t *testing.T) { + t.Parallel() + + for _, manifest := range findSampleManifests(t) { + name, err := filepath.Rel(samplesDir, manifest) + require.NoError(t, err) + + t.Run(filepath.ToSlash(name), func(t *testing.T) { + t.Parallel() + + content, err := os.ReadFile(manifest) + require.NoError(t, err) + + var agent PromptAgent + require.NoError(t, yaml.Unmarshal(content, &agent)) + + request, err := CreatePromptAgentAPIRequest(agent, nil) + require.NoError(t, err) + require.Equal(t, agent.Name, request.Name) + }) + } +} + +// assertSampleMemoryIsDeployable mirrors the memory validation the deploy graph +// performs, which lives in the project package and so cannot be called here. +func assertSampleMemoryIsDeployable(t *testing.T, memory *PromptMemory) { + t.Helper() + + if memory == nil { + return + } + + require.NotEmpty(t, memory.Store, "memory requires a store name") + require.NotEmpty(t, memory.ChatModel, "memory requires a chat model") + require.NotEmpty(t, memory.EmbeddingModel, "memory requires an embedding model") +} + +func findSampleManifests(t *testing.T) []string { + t.Helper() + + var manifests []string + err := filepath.WalkDir(samplesDir, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if !entry.IsDir() && entry.Name() == "agent.yaml" { + manifests = append(manifests, path) + } + return nil + }) + require.NoError(t, err) + + return manifests +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index 0a1051804b1..6daf23b5ae2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -246,26 +246,63 @@ type ContainerAgent struct { } // PromptAgent represents a Foundry "prompt" agent — a PES (Prompt Execution -// Service) backed agent whose Brain+Hand sandbox is provisioned by the -// platform on demand. The customer declares the model and instructions; the +// Service) backed agent. The customer declares the model and instructions; the // platform manages the runtime, lifecycle, and orchestration. // // Unlike ContainerAgent, the customer does not provide a container image or -// code; the only required fields are Model and Instructions. +// code; the only required fields are ModelDeploymentName and Instructions. +// +// The optional Harness field selects between the two prompt-agent flavors: +// - Harness empty — a plain prompt agent. Foundry runs model + instructions +// - tools directly; there is no sandbox to provision. +// - Harness set (e.g. "github-copilot") — a managed agent whose Brain+Hand +// sandbox is provisioned by the platform on demand and driven by the named +// harness. +// +// HarnessSkillRef is a published skill pinned onto a harnessed agent, resolved +// to the version that was actually uploaded. The version is carried explicitly +// because the service rejects a skill reference that omits it. +type HarnessSkillRef struct { + Name string + Version string +} + type PromptAgent struct { AgentDefinition `json:",inline" yaml:",inline"` - // Model is the model deployment name to use for this agent (e.g. "gpt-4.1-mini"). + // Model is the name of the model deployment the agent runs on (e.g. + // "gpt-4.1-mini") — not a model id. It must match a deployment declared + // under the sibling azure.ai.project service in azure.yaml, which + // `azd provision` creates. + // + // The key is `model` in both YAML and JSON, matching the field name the + // Foundry prompt-agent API expects on the wire. Model string `json:"model" yaml:"model"` - // Instructions is the system/developer message inserted into the model's context. - // It may be omitted here when supplied by a sibling instructions.md file - // (the deploy engine falls back to that convention); inline always wins. + // Harness names the execution harness the platform runs the agent on, for + // example agent_api.ManagedAgentHarnessGitHubCopilot ("github-copilot"). + // Leave it empty for a plain prompt agent with no harness; the field is then + // omitted from the create request entirely. + Harness string `json:"harness,omitempty" yaml:"harness,omitempty"` + + // Instructions is the system/developer message inserted into the model's + // context. It is declared inline, matching the prompt-agent API schema. Instructions string `json:"instructions,omitempty" yaml:"instructions,omitempty"` // Skills is an optional list of Foundry skill names attached to the agent. Skills []string `json:"skills,omitempty" yaml:"skills,omitempty"` + // HarnessSkills carries the skills a harnessed agent runs, resolved to the + // exact versions that were published. It is populated by the deploy graph + // from the agent's skills/ folder, never authored, and is therefore excluded + // from both YAML and JSON. + // + // It exists separately from Skills because the two land in different places + // on the wire: a harnessed agent's skills nest under `harness`, where the + // service provisions them into the sandbox, while the definition-level + // `skills` field only ever applies to a harness-less agent. + HarnessSkills []HarnessSkillRef `json:"-" yaml:"-"` + // Tools is an optional list of tool definitions attached to the agent. // Entries are passed through verbatim to the Foundry prompt-agent API, so // author them using the API's snake_case tool schema. Supported types @@ -281,13 +318,43 @@ type PromptAgent struct { // "required", "none", or a specific tool object). Passed through verbatim. ToolChoice any `json:"tool_choice,omitempty" yaml:"tool_choice,omitempty"` + // Temperature is the sampling temperature. Pointer so an explicit 0 (fully + // deterministic) is distinguishable from "not set", which would otherwise + // silently become the service default. + Temperature *float64 `json:"temperature,omitempty" yaml:"temperature,omitempty"` + + // TopP is the nucleus-sampling cutoff. Pointer for the same reason as + // Temperature. The API accepts both; setting both is usually a mistake. + TopP *float64 `json:"top_p,omitempty" yaml:"top_p,omitempty"` + + // Text configures the model's text response, most commonly the structured + // output format (e.g. text.format.type: json_schema). Passed through + // verbatim rather than modeled, since the shape is the API's to define. + Text any `json:"text,omitempty" yaml:"text,omitempty"` + + // Reasoning configures reasoning-model behavior (e.g. reasoning.effort). + // Only meaningful on models that support it; passed through verbatim. + Reasoning any `json:"reasoning,omitempty" yaml:"reasoning,omitempty"` + // StructuredInputs declares typed inputs the agent accepts per invocation. // Passed through verbatim to the API. StructuredInputs map[string]any `json:"structured_inputs,omitempty" yaml:"structured_inputs,omitempty"` - // Policies is an optional list of governance policies (e.g. RAI). + // Policies is an optional list of governance policies (e.g. RAI). This is + // how the "guardrails" capability is expressed: a rai_policy entry becomes + // the definition's rai_config, which is the only guardrail carrier the + // prompt-agent API has. Policies []Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + // Memory declares a Foundry memory store the agent recalls from. Unlike + // Tools this is NOT passed through: the prompt-agent API has no `memory` + // field. azd provisions the named store during deploy and then injects a + // memory_search_preview entry into Tools, which is the actual wire carrier. + // + // It is json:"-" for exactly that reason — emitting it would send a field + // the API does not define. + Memory *PromptMemory `json:"-" yaml:"memory,omitempty"` + // Connections declares project connections that the agent's tools depend on. // The deploy engine resolves each connection through the resolution ladder // (reference existing, create-if-missing, auto-fill target, provision) and @@ -301,6 +368,62 @@ type PromptAgent struct { Toolbox *ToolboxReference `json:"toolbox,omitempty" yaml:"toolbox,omitempty"` } +// PromptMemory declares the Foundry memory store a prompt agent recalls from. +// +// Memory is a two-part feature: a memory store is a project-level resource that +// must exist before the agent references it, and the agent reaches it through a +// memory_search_preview tool. Authors declare it once here and azd does both — +// it ensures the store exists at deploy time and injects the tool entry. +type PromptMemory struct { + // Store is the memory store name. Required. azd creates the store if it + // does not already exist and reuses it if it does. + Store string `json:"store" yaml:"store"` + + // Description is an optional human-readable description recorded on the + // store when azd creates it. + Description string `json:"description,omitempty" yaml:"description,omitempty"` + + // ChatModel and EmbeddingModel are the model deployment names the store + // uses to summarize conversations and to embed memories. Both are required + // to create a store; they are ignored when the store already exists. + ChatModel string `json:"chat_model,omitempty" yaml:"chat_model,omitempty"` + EmbeddingModel string `json:"embedding_model,omitempty" yaml:"embedding_model,omitempty"` + + // Scope namespaces memories so they are isolated per user (or per tenant, + // session, etc.). Defaults to DefaultMemoryScope, which resolves the caller's + // object ID from the request auth header at runtime. + Scope string `json:"scope,omitempty" yaml:"scope,omitempty"` + + // UpdateDelay is how many seconds of conversation inactivity to wait before + // extracting memories. Nil leaves the service default (300s) in place. Set + // it low only for demos — a short delay extracts on nearly every turn. + UpdateDelay *int `json:"update_delay,omitempty" yaml:"update_delay,omitempty"` + + // MaxMemories caps how many memories a single search returns. Nil leaves + // the service default in place. + MaxMemories *int `json:"max_memories,omitempty" yaml:"max_memories,omitempty"` + + // Options toggles which memory kinds the store extracts. + Options *PromptMemoryOptions `json:"options,omitempty" yaml:"options,omitempty"` +} + +// PromptMemoryOptions toggles the extraction behaviors of a memory store. All +// fields are pointers so an unset toggle leaves the service default rather than +// forcing false. +type PromptMemoryOptions struct { + ChatSummaryEnabled *bool `json:"chat_summary_enabled,omitempty" yaml:"chat_summary_enabled,omitempty"` + UserProfileEnabled *bool `json:"user_profile_enabled,omitempty" yaml:"user_profile_enabled,omitempty"` + ProceduralMemoryEnabled *bool `json:"procedural_memory_enabled,omitempty" yaml:"procedural_memory_enabled,omitempty"` + DefaultTTLSeconds *int `json:"default_ttl_seconds,omitempty" yaml:"default_ttl_seconds,omitempty"` + UserProfileDetails string `json:"user_profile_details,omitempty" yaml:"user_profile_details,omitempty"` +} + +// DefaultMemoryScope isolates memories per calling user. Foundry substitutes +// the object ID from the request's auth header, so a shared agent does not leak +// one user's memories to another. Authors can override it with a fixed string +// when they want a shared or per-tenant namespace instead. +const DefaultMemoryScope = "{{$userId}}" + // ToolboxReference points at an existing Foundry toolbox version so a prompt // agent can consume it without the deploy engine registering local skills. type ToolboxReference struct { @@ -337,11 +460,6 @@ type PromptConnection struct { // Metadata is optional additional connection metadata. Metadata map[string]string `json:"metadata,omitempty" yaml:"metadata,omitempty"` - - // Provision opts into the deploy engine creating the backing Azure resource - // (via an emitted Bicep module) when no existing connection or target can be - // resolved. Defaults to false (fail fast with guidance). - Provision bool `json:"provision,omitempty" yaml:"provision,omitempty"` } // AgentManifest The following represents a manifest that can be used to create agents dynamically. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client.go index 355d4ecd6bf..bdd7264f361 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client.go @@ -11,6 +11,7 @@ import ( "io" "mime/multipart" "net/http" + "net/url" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -183,6 +184,47 @@ func (c *FoundryFilesClient) CreateVectorStore( return &result, nil } +// addVectorStoreFileRequest is the body for attaching a file to a vector store. +type addVectorStoreFileRequest struct { + FileId string `json:"file_id"` +} + +// AddVectorStoreFile attaches an already-uploaded file to an existing vector +// store. It is used on the reuse path so re-deploying an agent updates the +// store it already has instead of creating a new one. Attaching a file the +// store already holds is a no-op on the service. +func (c *FoundryFilesClient) AddVectorStoreFile(ctx context.Context, storeID, fileID string) error { + payload, err := json.Marshal(addVectorStoreFileRequest{FileId: fileID}) + if err != nil { + return fmt.Errorf("marshaling request: %w", err) + } + + targetURL := fmt.Sprintf( + "%s/openai/%s/vector_stores/%s/files", + c.endpoint, filesAPIPathVersion, url.PathEscape(storeID), + ) + req, err := runtime.NewRequest(ctx, http.MethodPost, targetURL) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + if err := req.SetBody( + streaming.NopCloser(bytes.NewReader(payload)), + "application/json", + ); err != nil { + return fmt.Errorf("setting request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return runtime.NewResponseError(resp) + } + return nil +} + // decodeJSON reads and unmarshals a JSON response body. func decodeJSON(r io.Reader, v any) error { body, err := io.ReadAll(r) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go index d8ccd3ff3ef..ed633bf399d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go @@ -4,7 +4,6 @@ package azure import ( - "bytes" "context" "encoding/json" "fmt" @@ -16,7 +15,6 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" "github.com/azure/azure-dev/cli/azd/pkg/azsdk" "azureaiagent/internal/version" @@ -64,19 +62,6 @@ func NewFoundryToolboxClient( } } -// CreateToolboxVersionRequest is the request body for creating a new toolbox version. -// The toolbox name is provided in the URL path, not in the body. -// -// Skills are attached via a separate top-level `skills` array (skill references), -// distinct from `tools`. Each skill reference is -// {"type": "skill_reference", "name": , "version": }. -type CreateToolboxVersionRequest struct { - Description string `json:"description,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Tools []map[string]any `json:"tools"` - Skills []map[string]any `json:"skills,omitempty"` -} - // ToolboxObject is the lightweight response for a toolbox (no tools list). type ToolboxObject struct { Id string `json:"id"` @@ -84,69 +69,6 @@ type ToolboxObject struct { DefaultVersion string `json:"default_version"` } -// ToolboxVersionObject is the response for a specific toolbox version. -type ToolboxVersionObject struct { - Id string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description,omitempty"` - CreatedAt int64 `json:"created_at"` - Metadata map[string]string `json:"metadata,omitempty"` - Tools []map[string]any `json:"tools"` -} - -// CreateToolboxVersion creates a new version of a toolbox. -// If the toolbox does not exist, it will be created automatically. -func (c *FoundryToolboxClient) CreateToolboxVersion( - ctx context.Context, - toolboxName string, - request *CreateToolboxVersionRequest, -) (*ToolboxVersionObject, error) { - targetUrl := fmt.Sprintf( - "%s/toolboxes/%s/versions?api-version=%s", - c.endpoint, url.PathEscape(toolboxName), toolboxesApiVersion, - ) - - payload, err := json.Marshal(request) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - req, err := runtime.NewRequest(ctx, http.MethodPost, targetUrl) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - if err := req.SetBody( - streaming.NopCloser(bytes.NewReader(payload)), - "application/json", - ); err != nil { - return nil, fmt.Errorf("failed to set request body: %w", err) - } - - resp, err := c.pipeline.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { - return nil, runtime.NewResponseError(resp) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var result ToolboxVersionObject - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return &result, nil -} - // GetToolbox retrieves a toolbox by name. func (c *FoundryToolboxClient) GetToolbox( ctx context.Context, @@ -212,50 +134,3 @@ func (c *FoundryToolboxClient) DeleteToolbox( return nil } - -// PromoteToolboxVersion updates the toolbox's default_version, making it the -// version the consumer MCP endpoint (/toolboxes/{name}/mcp) serves. Creating a -// toolbox version does NOT automatically promote it — the Foundry API tracks -// default_version separately, and the first version created for a brand-new -// toolbox is the only one auto-promoted. Every subsequent version must be -// promoted explicitly for consumers (including the Foundry portal skill/tool -// view) to see it. -// -// PATCH {endpoint}/toolboxes/{name}?api-version=v1 -func (c *FoundryToolboxClient) PromoteToolboxVersion( - ctx context.Context, - toolboxName string, - version string, -) error { - targetUrl := fmt.Sprintf( - "%s/toolboxes/%s?api-version=%s", - c.endpoint, url.PathEscape(toolboxName), toolboxesApiVersion, - ) - - payload, err := json.Marshal(map[string]string{"default_version": version}) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) - } - - req, err := runtime.NewRequest(ctx, http.MethodPatch, targetUrl) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - if err := req.SetBody( - streaming.NopCloser(bytes.NewReader(payload)), - "application/json", - ); err != nil { - return fmt.Errorf("failed to set request body: %w", err) - } - - resp, err := c.pipeline.Do(req) - if err != nil { - return fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - if !runtime.HasStatusCode(resp, http.StatusOK) { - return runtime.NewResponseError(resp) - } - return nil -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go index e20bdf44c7e..7316c70c79b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go @@ -48,120 +48,6 @@ func newTestToolboxClient( } } -func TestCreateToolboxVersion_URLConstruction(t *testing.T) { - tests := []struct { - name string - endpoint string - toolboxName string - wantPath string - wantQuery string - }{ - { - name: "simple name", - endpoint: "https://example.com", - toolboxName: "my-toolbox", - wantPath: "/toolboxes/my-toolbox/versions", - wantQuery: "api-version=" + toolboxesApiVersion, - }, - { - name: "name with special chars is escaped", - endpoint: "https://example.com", - toolboxName: "my toolbox/v2", - wantPath: "/toolboxes/my%20toolbox%2Fv2/versions", - wantQuery: "api-version=" + toolboxesApiVersion, - }, - { - name: "endpoint with trailing slash", - endpoint: "https://example.com/", - toolboxName: "tools", - wantPath: "//toolboxes/tools/versions", - wantQuery: "api-version=" + toolboxesApiVersion, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var capturedReq *http.Request - - client := newTestToolboxClient(tt.endpoint, func(req *http.Request) (*http.Response, error) { - capturedReq = req - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"id":"1","name":"tb","version":"v1","tools":[]}`)), - Header: make(http.Header), - }, nil - }) - - _, err := client.CreateToolboxVersion(t.Context(), tt.toolboxName, &CreateToolboxVersionRequest{ - Tools: []map[string]any{}, - }) - require.NoError(t, err) - require.NotNil(t, capturedReq) - - require.Equal(t, http.MethodPost, capturedReq.Method) - require.Equal(t, tt.wantPath, capturedReq.URL.EscapedPath()) - require.Equal(t, tt.wantQuery, capturedReq.URL.RawQuery) - }) - } -} - -func TestCreateToolboxVersion_RequiredHeaders(t *testing.T) { - var capturedReq *http.Request - - client := newTestToolboxClient("https://example.com", func(req *http.Request) (*http.Response, error) { - capturedReq = req - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"id":"1","name":"tb","version":"v1","tools":[]}`)), - Header: make(http.Header), - }, nil - }) - - _, err := client.CreateToolboxVersion(t.Context(), "test-toolbox", &CreateToolboxVersionRequest{ - Tools: []map[string]any{}, - }) - require.NoError(t, err) - require.NotNil(t, capturedReq) - - require.Equal(t, "application/json", capturedReq.Header.Get("Content-Type")) -} - -func TestCreateToolboxVersion_ErrorStatusCodes(t *testing.T) { - tests := []struct { - name string - statusCode int - wantErr bool - }{ - {"200 OK", http.StatusOK, false}, - {"400 Bad Request", http.StatusBadRequest, true}, - {"404 Not Found", http.StatusNotFound, true}, - {"409 Conflict", http.StatusConflict, true}, - {"500 Internal Server Error", http.StatusInternalServerError, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := newTestToolboxClient("https://example.com", func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: tt.statusCode, - Body: io.NopCloser(strings.NewReader(`{"id":"1","name":"tb","version":"v1","tools":[]}`)), - Header: make(http.Header), - }, nil - }) - - _, err := client.CreateToolboxVersion(t.Context(), "test", &CreateToolboxVersionRequest{ - Tools: []map[string]any{}, - }) - - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} - func TestGetToolbox_URLConstruction(t *testing.T) { tests := []struct { name string @@ -284,41 +170,3 @@ func TestToolboxClient_PathEscaping_Adversarial(t *testing.T) { }) } } - -func TestPromoteToolboxVersion_RequestShape(t *testing.T) { - var captured *http.Request - var body []byte - - client := newTestToolboxClient("https://example.com", func(req *http.Request) (*http.Response, error) { - captured = req - if req.Body != nil { - body, _ = io.ReadAll(req.Body) - } - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"id":"1","name":"tb","default_version":"v2"}`)), - Header: make(http.Header), - }, nil - }) - - err := client.PromoteToolboxVersion(t.Context(), "tb", "v2") - require.NoError(t, err) - - require.NotNil(t, captured) - require.Equal(t, http.MethodPatch, captured.Method) - require.Equal(t, "/toolboxes/tb", captured.URL.EscapedPath()) - require.Equal(t, "api-version="+toolboxesApiVersion, captured.URL.RawQuery) - require.Contains(t, string(body), `"default_version":"v2"`) -} - -func TestPromoteToolboxVersion_ErrorStatus(t *testing.T) { - client := newTestToolboxClient("https://example.com", func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusBadRequest, - Body: io.NopCloser(strings.NewReader(`{"error":"bad"}`)), - Header: make(http.Header), - }, nil - }) - err := client.PromoteToolboxVersion(t.Context(), "tb", "v2") - require.Error(t, err) -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_manifest_ref_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_manifest_ref_test.go new file mode 100644 index 00000000000..d677bb6f1b7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_manifest_ref_test.go @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func mustStruct(t *testing.T, fields map[string]any) *structpb.Struct { + t.Helper() + + s, err := structpb.NewStruct(fields) + require.NoError(t, err) + return s +} + +// TestDeclaredAgentManifest covers where the `manifest:` key may live on a +// service entry. Service-level properties win over the nested config block so +// the unified azure.yaml shape reads the same way the inline agent definition +// does. +func TestDeclaredAgentManifest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + svc *azdext.ServiceConfig + want string + }{ + { + name: "nil service", + }, + { + name: "no manifest declared falls back to the convention", + svc: &azdext.ServiceConfig{Name: "agent"}, + }, + { + name: "service-level manifest", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"manifest": "agent.yaml"}), + }, + want: "agent.yaml", + }, + { + name: "config-level manifest", + svc: &azdext.ServiceConfig{ + Config: mustStruct(t, map[string]any{"manifest": "nested.yaml"}), + }, + want: "nested.yaml", + }, + { + name: "service-level wins over config-level", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"manifest": "outer.yaml"}), + Config: mustStruct(t, map[string]any{"manifest": "inner.yaml"}), + }, + want: "outer.yaml", + }, + { + name: "blank value is treated as undeclared", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"manifest": " "}), + }, + }, + { + name: "non-string value is ignored", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"manifest": 42}), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tc.want, declaredAgentManifest(tc.svc)) + }) + } +} + +// TestResolveDeclaredManifestPath pins the confinement rules. A manifest is +// part of one service's source, so it must stay inside that service's project +// directory even when the path would still land inside the azd project. +func TestResolveDeclaredManifestPath(t *testing.T) { + t.Parallel() + + root := t.TempDir() + + tests := []struct { + name string + servicePath string + declared string + wantRel string + wantErr bool + }{ + { + name: "sibling file", + servicePath: "src/triage", + declared: "agent.yaml", + wantRel: filepath.Join("src", "triage", "agent.yaml"), + }, + { + name: "nested file", + servicePath: "src/triage", + declared: "agents/primary.yml", + wantRel: filepath.Join("src", "triage", "agents", "primary.yml"), + }, + { + name: "service at project root", + servicePath: ".", + declared: "agent.yaml", + wantRel: "agent.yaml", + }, + { + name: "escaping the service directory is rejected", + servicePath: "src/triage", + declared: "../other/agent.yaml", + wantErr: true, + }, + { + name: "escaping the project root is rejected", + servicePath: "src/triage", + declared: "../../../agent.yaml", + wantErr: true, + }, + { + name: "absolute paths are rejected", + servicePath: "src/triage", + declared: "/etc/agent.yaml", + wantErr: true, + }, + { + name: "non-YAML extensions are rejected", + servicePath: "src/triage", + declared: "agent.json", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := resolveDeclaredManifestPath(root, tc.servicePath, tc.declared, "triage-agent") + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, filepath.Join(root, tc.wantRel), got) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/memory_store.go b/cli/azd/extensions/azure.ai.agents/internal/project/memory_store.go new file mode 100644 index 00000000000..652b1ac37f1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/memory_store.go @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "fmt" + "strings" + + "azureaiagent/internal/pkg/azure" +) + +// A memory store can be declared from two different surfaces: `memoryStores:` +// on an agent service in azure.yaml (hosted agents) and `memory:` in agent.yaml +// (prompt agents). The two authoring shapes differ, but everything downstream of +// them -- the request the service accepts, the rule for when options may be +// omitted, and what counts as drift against an existing store -- is identical. +// That shared half lives here, keyed off the wire types, so the two surfaces +// cannot disagree about how a store is created or compared. + +// memoryStoreOptionsOrNil returns options, or nil when every field is unset. +// +// The nil matters: a store is only configured at creation, and the service +// applies its own defaults for an omitted options object. Sending an empty +// object instead risks the service reading it as "explicitly default +// everything", which is not what an author who wrote no options asked for. +func memoryStoreOptionsOrNil(options *azure.MemoryStoreOptions) *azure.MemoryStoreOptions { + if options == nil { + return nil + } + if options.ChatSummaryEnabled == nil && + options.UserProfileEnabled == nil && + options.ProceduralMemoryEnabled == nil && + options.DefaultTTLSeconds == nil && + options.UserProfileDetails == "" { + return nil + } + return options +} + +// memoryStoreDrift is one field whose declared value diverges from the live +// store. It is reported rather than applied: azd creates memory stores but never +// updates them, so an edit to a store that already exists has no effect, and +// silently ignoring it would leave the manifest and the resource disagreeing +// indefinitely. +type memoryStoreDrift struct { + // Field is the wire field path, e.g. "chat_model" or + // "options.chat_summary_enabled". Callers map it to the key name used by + // the surface the author actually wrote. + Field string + // Declared is the value in the manifest, formatted for display. + Declared string + // Live is the store's current value, formatted for display. It is empty + // when the store leaves the field at its service default, which is not + // something the caller can usefully print back to the author. + Live string +} + +// diffMemoryStoreDefinition reports the fields where declared diverges from +// live. Only fields the author explicitly declared are compared, so unset +// options -- which fall back to service defaults -- never produce false drift, +// and a live-only field the author never mentioned is ignored. +// +// A model the service did not echo back is treated as unknown rather than as +// drift: a response that omits the definition is not evidence the store differs, +// and reporting it would warn on every deploy. +func diffMemoryStoreDefinition(declared, live azure.MemoryStoreDefinition) []memoryStoreDrift { + var drift []memoryStoreDrift + + add := func(field, declaredVal, liveVal string) { + drift = append(drift, memoryStoreDrift{Field: field, Declared: declaredVal, Live: liveVal}) + } + + if want, got := strings.TrimSpace(declared.ChatModel), strings.TrimSpace(live.ChatModel); // + got != "" && want != got { + add("chat_model", want, got) + } + if want, got := strings.TrimSpace(declared.EmbeddingModel), strings.TrimSpace(live.EmbeddingModel); // + got != "" && want != got { + add("embedding_model", want, got) + } + + if declared.Options == nil { + return drift + } + + var liveOpts azure.MemoryStoreOptions + if live.Options != nil { + liveOpts = *live.Options + } + + for _, opt := range []struct { + field string + declared, live_ *bool + }{ + {"options.chat_summary_enabled", declared.Options.ChatSummaryEnabled, liveOpts.ChatSummaryEnabled}, + {"options.user_profile_enabled", declared.Options.UserProfileEnabled, liveOpts.UserProfileEnabled}, + { + "options.procedural_memory_enabled", + declared.Options.ProceduralMemoryEnabled, + liveOpts.ProceduralMemoryEnabled, + }, + } { + if boolPtrDiffers(opt.declared, opt.live_) { + add(opt.field, fmt.Sprintf("%v", *opt.declared), formatBoolPtr(opt.live_)) + } + } + + if declared.Options.DefaultTTLSeconds != nil && + (liveOpts.DefaultTTLSeconds == nil || *declared.Options.DefaultTTLSeconds != *liveOpts.DefaultTTLSeconds) { + liveTTL := "" + if liveOpts.DefaultTTLSeconds != nil { + liveTTL = fmt.Sprintf("%d", *liveOpts.DefaultTTLSeconds) + } + add("options.default_ttl_seconds", fmt.Sprintf("%d", *declared.Options.DefaultTTLSeconds), liveTTL) + } + + if declared.Options.UserProfileDetails != "" && + declared.Options.UserProfileDetails != liveOpts.UserProfileDetails { + add("options.user_profile_details", declared.Options.UserProfileDetails, liveOpts.UserProfileDetails) + } + + return drift +} + +// boolPtrDiffers reports whether a declared bool pointer is set and differs from +// the live value. An unset live value differs from any declared one: the store +// is on the service default, not on what the author asked for. +func boolPtrDiffers(declared, live *bool) bool { + if declared == nil { + return false + } + return live == nil || *declared != *live +} + +// formatBoolPtr renders a bool pointer for a drift message, using the empty +// string for unset so callers can suppress the "current" half. +func formatBoolPtr(value *bool) string { + if value == nil { + return "" + } + return fmt.Sprintf("%v", *value) +} + +// describeMemoryStoreDrift renders drift entries as human-readable phrases, +// mapping each wire field path through labels so the message names the key the +// author actually wrote. A field absent from labels is printed as-is, which is +// what the agent.yaml surface wants since it uses the wire names verbatim. +func describeMemoryStoreDrift(drift []memoryStoreDrift, labels map[string]string) []string { + described := make([]string, 0, len(drift)) + for _, d := range drift { + label := d.Field + if mapped, ok := labels[d.Field]; ok { + label = mapped + } + if d.Live == "" { + described = append(described, fmt.Sprintf("%s (declared %q)", label, d.Declared)) + continue + } + described = append(described, fmt.Sprintf("%s (declared %q, current %q)", label, d.Declared, d.Live)) + } + return described +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client.go index b7d288b547c..3357a97a031 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client.go @@ -55,23 +55,39 @@ const DefaultPromptAPIVersion = "2025-05-15-preview" // DefaultPromptModelEndpoint is the model gateway the harness calls to reach // the LLM. It is sent on invoke (Responses) requests via the x-model-endpoint // header. +// +// This is a private development resource and is only a last-resort fallback: +// EffectiveModelEndpoint prefers the user's own resolved Foundry project +// endpoint, which is the correct gateway for anyone outside the dev +// subscription. Do not rely on this constant being reachable. const DefaultPromptModelEndpoint = "https://va-dev-fdp-resource.services.ai.azure.com" // PromptAgentSettings captures the harness connection details for a prompt // (kind=managed) agent. It is stored in the azure.yaml service config block // (ServiceTargetAgentConfig.PromptAgent) and resolved at deploy/invoke time. +// +// `azd ai agent init` writes every field as a ${VAR} reference rather than a +// literal, so azure.yaml carries no subscription, resource group, or workspace +// of its own and can be copied between Foundry projects unchanged. Deploy +// expands the references against the azd environment and falls back to the +// built-in defaults for any variable that is unset. +// +// Every field is omitempty so a field with nothing to say is left out entirely. +// Persisting empty strings would put a shape into azure.yaml that carries no +// information but looks like configuration a developer must fill in, and +// overlay() treats an empty value as "not configured" in either case. type PromptAgentSettings struct { - // BaseURL is the harness origin (scheme + host [+ port]). Required. - BaseURL string `json:"baseUrl"` + // BaseURL is the harness origin (scheme + host [+ port]). + BaseURL string `json:"baseUrl,omitempty"` // SubscriptionID is the Azure subscription containing the workspace. - SubscriptionID string `json:"subscriptionId"` + SubscriptionID string `json:"subscriptionId,omitempty"` // ResourceGroup is the Azure resource group containing the workspace. - ResourceGroup string `json:"resourceGroup"` + ResourceGroup string `json:"resourceGroup,omitempty"` // Workspace is the Azure ML / Foundry workspace name. - Workspace string `json:"workspace"` + Workspace string `json:"workspace,omitempty"` // ProjectEndpoint is the Foundry project data-plane root // (https://.services.ai.azure.com/api/projects/). When set, @@ -105,6 +121,32 @@ func DefaultPromptAgentSettings() PromptAgentSettings { } } +// overlay copies every non-empty field of src onto s, leaving s's existing +// value in place where src is empty. It lets a partially populated (or empty) +// promptAgent block in azure.yaml layer over DefaultPromptAgentSettings without +// blanking the defaults. +func (s *PromptAgentSettings) overlay(src *PromptAgentSettings) { + if s == nil || src == nil { + return + } + for _, f := range []struct { + dst *string + src string + }{ + {&s.BaseURL, src.BaseURL}, + {&s.SubscriptionID, src.SubscriptionID}, + {&s.ResourceGroup, src.ResourceGroup}, + {&s.Workspace, src.Workspace}, + {&s.ProjectEndpoint, src.ProjectEndpoint}, + {&s.APIVersion, src.APIVersion}, + {&s.ModelEndpoint, src.ModelEndpoint}, + } { + if v := strings.TrimSpace(f.src); v != "" { + *f.dst = v + } + } +} + // Validate reports a typed error when any required field is empty. func (s *PromptAgentSettings) Validate() error { if s == nil { @@ -146,13 +188,30 @@ func (s *PromptAgentSettings) EffectiveAPIVersion() string { return strings.TrimSpace(s.APIVersion) } -// EffectiveModelEndpoint returns the configured model endpoint, falling back -// to the package-level default when empty. +// EffectiveModelEndpoint returns the model gateway to advertise to the +// harness. An explicitly configured ModelEndpoint wins. Otherwise the resolved +// Foundry project endpoint is used, because the model deployments this agent +// references live in the user's own project — falling straight through to the +// shared development default would send every user's traffic at a resource +// they cannot access. func (s *PromptAgentSettings) EffectiveModelEndpoint() string { - if s == nil || strings.TrimSpace(s.ModelEndpoint) == "" { + if s == nil { return DefaultPromptModelEndpoint } - return s.ModelEndpoint + if v := strings.TrimSpace(s.ModelEndpoint); v != "" && v != DefaultPromptModelEndpoint { + return v + } + if pe := strings.TrimSpace(s.ProjectEndpoint); pe != "" { + // Trim the /api/projects/ suffix: the model gateway is the + // account origin, not the project-scoped data-plane route. + if u, err := url.Parse(pe); err == nil && u.Scheme != "" && u.Host != "" { + return u.Scheme + "://" + u.Host + } + } + if strings.TrimSpace(s.ModelEndpoint) != "" { + return s.ModelEndpoint + } + return DefaultPromptModelEndpoint } // ApplyEnvOverrides updates any non-empty environment variables into the diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go index 3b1ffa5f2ec..8030da6d542 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go @@ -4,9 +4,12 @@ package project import ( + "errors" "testing" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestDefaultPromptAgentSettings_PublicDefaults asserts the defaults point at @@ -107,6 +110,75 @@ func TestPromptAgentSettings_ApplyEnvOverrides(t *testing.T) { } } +// TestExpandPromptAgentSettings asserts the ${VAR} references `azd ai agent +// init` writes into the promptAgent block resolve against the azd environment, +// that unset references collapse to "" (so overlay leaves the defaults in +// place), and that literal values are passed through untouched. +func TestExpandPromptAgentSettings(t *testing.T) { + // Not parallel: the unset case pins the referenced variables to empty via + // t.Setenv so a developer who exports them locally still sees the unset + // behavior (expansion falls back to the process environment). + const endpoint = "https://acct.services.ai.azure.com/api/projects/p1" + refs := &PromptAgentSettings{ + BaseURL: "${AZD_MANAGED_AGENT_BASE_URL}", + SubscriptionID: "${AZURE_SUBSCRIPTION_ID}", + ResourceGroup: "${AZURE_RESOURCE_GROUP}", + Workspace: "${AZURE_AI_WORKSPACE}", + ProjectEndpoint: "${AZURE_AI_PROJECT_ENDPOINT}", + } + + t.Run("resolves references from the azd environment", func(t *testing.T) { + got, err := expandPromptAgentSettings(refs, map[string]string{ + "AZD_MANAGED_AGENT_BASE_URL": "https://harness.example", + "AZURE_SUBSCRIPTION_ID": "sub-1", + "AZURE_RESOURCE_GROUP": "rg-1", + "AZURE_AI_WORKSPACE": "acct@p1@AML", + "AZURE_AI_PROJECT_ENDPOINT": endpoint, + }) + + require.NoError(t, err) + assert.Equal(t, "https://harness.example", got.BaseURL) + assert.Equal(t, "sub-1", got.SubscriptionID) + assert.Equal(t, "rg-1", got.ResourceGroup) + assert.Equal(t, "acct@p1@AML", got.Workspace) + assert.Equal(t, endpoint, got.ProjectEndpoint) + }) + + t.Run("unset references fall back to the defaults", func(t *testing.T) { + for _, name := range []string{ + "AZD_MANAGED_AGENT_BASE_URL", + "AZURE_SUBSCRIPTION_ID", + "AZURE_RESOURCE_GROUP", + "AZURE_AI_WORKSPACE", + "AZURE_AI_PROJECT_ENDPOINT", + } { + t.Setenv(name, "") + } + + got, err := expandPromptAgentSettings(refs, nil) + require.NoError(t, err) + assert.Empty(t, got.BaseURL) + assert.Empty(t, got.Workspace) + + // overlay must not blank the defaults with the empty expansions. + settings := DefaultPromptAgentSettings() + settings.overlay(got) + assert.Equal(t, DefaultPromptBaseURL, settings.BaseURL) + assert.Equal(t, DefaultPromptWorkspace, settings.Workspace) + }) + + t.Run("literal values are preserved", func(t *testing.T) { + got, err := expandPromptAgentSettings(&PromptAgentSettings{ + BaseURL: "https://literal.example", + Workspace: "acct@p1@AML", + }, nil) + + require.NoError(t, err) + assert.Equal(t, "https://literal.example", got.BaseURL) + assert.Equal(t, "acct@p1@AML", got.Workspace) + }) +} + // TestNewPromptAgentClient_BuildsClient asserts a client builds from valid // settings (no-auth path to avoid requiring an Azure login in tests). func TestNewPromptAgentClient_BuildsClient(t *testing.T) { @@ -293,7 +365,7 @@ func TestOverlayPromptSettingsFromProjectResourceID(t *testing.T) { if err == nil { t.Fatalf("expected error") } - localErr, ok := err.(*azdext.LocalError) + localErr, ok := errors.AsType[*azdext.LocalError](err) if !ok { t.Fatalf("expected *azdext.LocalError, got %T", err) } @@ -368,4 +440,44 @@ func TestResolvePromptTargetFromEnv_ProjectEndpoint(t *testing.T) { t.Errorf("ProjectEndpoint should keep config value, got %q", s.ProjectEndpoint) } }) + + // A greenfield `azd up` provisions the project through the microsoft.foundry + // provider, which writes FOUNDRY_PROJECT_ENDPOINT (not the older + // AZURE_AI_PROJECT_ENDPOINT). Without this fallback the deploy drops to the + // legacy workspace-rooted harness route and gets a 404. + t.Run("falls back to FOUNDRY_PROJECT_ENDPOINT", func(t *testing.T) { + s := DefaultPromptAgentSettings() + env := map[string]string{ + "AZURE_AI_PROJECT_NAME": "proj-1", + "FOUNDRY_PROJECT_ENDPOINT": "https://acct-1.services.ai.azure.com/api/projects/proj-1", + } + applied, err := ResolvePromptTargetFromEnv(&s, env) + if err != nil { + t.Fatalf("ResolvePromptTargetFromEnv: %v", err) + } + if !applied { + t.Fatalf("expected project-scoped target to be applied") + } + if s.ProjectEndpoint != "https://acct-1.services.ai.azure.com/api/projects/proj-1" { + t.Errorf("ProjectEndpoint: got %q", s.ProjectEndpoint) + } + if s.EffectiveAPIVersion() != ProjectEndpointAPIVersion { + t.Errorf("APIVersion: got %q, want %q", s.EffectiveAPIVersion(), ProjectEndpointAPIVersion) + } + }) + + t.Run("AZURE_AI_PROJECT_ENDPOINT wins over FOUNDRY_PROJECT_ENDPOINT", func(t *testing.T) { + s := DefaultPromptAgentSettings() + env := map[string]string{ + "AZURE_AI_PROJECT_NAME": "proj-1", + "AZURE_AI_PROJECT_ENDPOINT": "https://azure-acct.services.ai.azure.com/api/projects/proj-1", + "FOUNDRY_PROJECT_ENDPOINT": "https://foundry-acct.services.ai.azure.com/api/projects/proj-1", + } + if _, err := ResolvePromptTargetFromEnv(&s, env); err != nil { + t.Fatalf("ResolvePromptTargetFromEnv: %v", err) + } + if s.ProjectEndpoint != "https://azure-acct.services.ai.azure.com/api/projects/proj-1" { + t.Errorf("ProjectEndpoint: got %q", s.ProjectEndpoint) + } + }) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go index 26a777b46d9..25aa8932c9a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go @@ -7,6 +7,8 @@ import ( "context" "fmt" "net/url" + "os" + "regexp" "strings" "azureaiagent/internal/exterrors" @@ -16,6 +18,49 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/output" ) +// credentialPlaceholderPattern matches a whole-value ${ENV_VAR} reference in a +// connection credential. +var credentialPlaceholderPattern = regexp.MustCompile(`^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$`) + +// expandCredentialPlaceholders returns a copy of credentials with any +// whole-value ${ENV_VAR} string replaced by that variable's value. An unset +// variable is an error: passing the literal placeholder to the service would +// silently store an unusable secret. Non-string and non-placeholder values are +// copied through unchanged. +func expandCredentialPlaceholders( + connectionName string, credentials map[string]any, +) (map[string]any, error) { + if len(credentials) == 0 { + return credentials, nil + } + out := make(map[string]any, len(credentials)) + for key, value := range credentials { + str, isString := value.(string) + if !isString { + out[key] = value + continue + } + match := credentialPlaceholderPattern.FindStringSubmatch(strings.TrimSpace(str)) + if match == nil { + out[key] = value + continue + } + resolved, ok := os.LookupEnv(match[1]) + if !ok || resolved == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf( + "connection %q credential %q references environment variable %q, which is not set", + connectionName, key, match[1], + ), + fmt.Sprintf("set %s in the environment before running `azd deploy`", match[1]), + ) + } + out[key] = resolved + } + return out, nil +} + // connectionAction is the resolution outcome for a single declared connection. type connectionAction int @@ -26,11 +71,8 @@ const ( // connActionCreate means the connection is created against a known target // (ladder rung 2, with the target possibly auto-filled at rung 3). connActionCreate - // connActionProvision means the backing resource must be provisioned first - // (ladder rung 4, opt-in via Provision). - connActionProvision // connActionFailFast means nothing could be resolved and the user must act - // (ladder rung 4, no opt-in). + // (ladder rung 4). connActionFailFast ) @@ -73,7 +115,7 @@ func targetFromEnv(name string, env map[string]string) string { if !ok || strings.TrimSpace(raw) == "" { continue } - for _, pair := range strings.Split(raw, ";") { + for pair := range strings.SplitSeq(raw, ";") { pair = strings.TrimSpace(pair) eq := strings.IndexByte(pair, '=') if eq <= 0 { @@ -124,16 +166,17 @@ func resolveConnectionAction( return connActionCreate, resolved, nil } - // Rung 4: no target — provision if opted in, else fail fast. - if decl.Provision { - return connActionProvision, resolved, nil - } + // Rung 4: no target and nothing to derive one from. azd connects an agent to + // a resource; it does not create the resource, so this is where the author + // has to act. return connActionFailFast, resolved, exterrors.Validation( exterrors.CodeInvalidAgentManifest, fmt.Sprintf( "connection %q has no existing connection and no resolvable target", decl.Name, ), - "set connections["+decl.Name+"].target, or set provision: true to create the backing resource", + "provision the backing resource with infrastructure (Bicep/Terraform) and set "+ + "connections[].target on the entry named "+decl.Name+" to its endpoint, or create "+ + "the connection in the Foundry portal under that name", ) } @@ -197,7 +240,7 @@ func connectionsNode( switch action { case connActionUseExisting: // Nothing to create. - case connActionCreate, connActionProvision: + case connActionCreate: id, createErr := resolver.Create(ctx, resolved) if createErr != nil { return fmt.Errorf("creating connection %q: %w", resolved.Name, createErr) @@ -209,7 +252,7 @@ func connectionsNode( return exterrors.Validation( exterrors.CodeInvalidAgentManifest, fmt.Sprintf("connection %q could not be resolved", resolved.Name), - "declare a target or set provision: true", + "set connections[].target to the backing resource's endpoint", ) } } @@ -293,14 +336,21 @@ func (r *foundryConnectionResolver) Existing(ctx context.Context) (map[string]st } // Create creates a connection from the declaration, defaulting to Entra auth. +// ${ENV_VAR} placeholders in credentials are expanded from the process +// environment first; sending them through literally would store the text +// "${MY_KEY}" as the secret and fail at first use. func (r *foundryConnectionResolver) Create( ctx context.Context, decl agent_yaml.PromptConnection, ) (string, error) { + credentials, err := expandCredentialPlaceholders(decl.Name, decl.Credentials) + if err != nil { + return "", err + } created, err := r.client.CreateConnection(ctx, decl.Name, &azure.CreateConnectionRequest{ Category: decl.Category, Target: decl.Target, AuthType: decl.AuthType, // empty defaults to AAD in the client - Credentials: decl.Credentials, + Credentials: credentials, Metadata: decl.Metadata, }) if err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_creds_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_creds_test.go new file mode 100644 index 00000000000..5af57ce231d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_creds_test.go @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import "testing" + +// TestExpandCredentialPlaceholders verifies ${ENV_VAR} credential references +// are resolved before reaching the connection API, and that an unset variable +// fails loudly instead of storing the literal placeholder as the secret. +func TestExpandCredentialPlaceholders(t *testing.T) { + t.Setenv("PROMPT_TEST_API_KEY", "s3cret") + + got, err := expandCredentialPlaceholders("search", map[string]any{ + "key": "${PROMPT_TEST_API_KEY}", + "literal": "not-a-placeholder", + "number": 42, + }) + if err != nil { + t.Fatalf("expandCredentialPlaceholders: %v", err) + } + if got["key"] != "s3cret" { + t.Errorf("key: got %v, want s3cret", got["key"]) + } + if got["literal"] != "not-a-placeholder" { + t.Errorf("literal: got %v", got["literal"]) + } + if got["number"] != 42 { + t.Errorf("number: got %v", got["number"]) + } + + if _, err := expandCredentialPlaceholders("search", map[string]any{ + "key": "${PROMPT_TEST_MISSING_KEY}", + }); err == nil { + t.Error("expected an error for an unset environment variable") + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go index 55de625143b..6c72c16786d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go @@ -59,18 +59,7 @@ func TestResolveConnectionAction_Rung3_AutoFillTarget(t *testing.T) { } } -func TestResolveConnectionAction_Rung4_ProvisionOptIn(t *testing.T) { - decl := agent_yaml.PromptConnection{Name: "search-conn", Category: "CognitiveSearch", Provision: true} - action, _, err := resolveConnectionAction(decl, map[string]string{}, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if action != connActionProvision { - t.Errorf("action: got %v, want provision", action) - } -} - -func TestResolveConnectionAction_Rung4_FailFastNoOptIn(t *testing.T) { +func TestResolveConnectionAction_Rung4_FailFast(t *testing.T) { decl := agent_yaml.PromptConnection{Name: "search-conn", Category: "CognitiveSearch"} action, _, err := resolveConnectionAction(decl, map[string]string{}, nil) if err == nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go index 05bb1a4b137..858e8c9e5a3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go @@ -12,52 +12,27 @@ import ( "azureaiagent/internal/pkg/agents/agent_yaml" ) -// writeAgentYAML writes an agent.yaml (and optional instructions.md) into a temp -// dir and returns a provider pointed at it. -func writeAgentYAML(t *testing.T, agentYAML string, instructionsMD *string) *AgentServiceTargetProvider { +// writeAgentYAML writes an agent.yaml into a temp dir and returns a provider +// pointed at it. +func writeAgentYAML(t *testing.T, agentYAML string) *AgentServiceTargetProvider { t.Helper() dir := t.TempDir() agentPath := filepath.Join(dir, "agent.yaml") if err := os.WriteFile(agentPath, []byte(agentYAML), 0o600); err != nil { t.Fatalf("write agent.yaml: %v", err) } - if instructionsMD != nil { - if err := os.WriteFile(filepath.Join(dir, "instructions.md"), []byte(*instructionsMD), 0o600); err != nil { - t.Fatalf("write instructions.md: %v", err) - } - } return &AgentServiceTargetProvider{agentDefinitionPath: agentPath} } -// TestLoadPromptDef_InstructionsFileFallback verifies a sibling instructions.md -// supplies the agent's instructions when none are declared inline. -func TestLoadPromptDef_InstructionsFileFallback(t *testing.T) { - md := "You are a careful assistant.\nAnswer concisely." - p := writeAgentYAML(t, ` -kind: prompt -name: file-instr -model: gpt-4.1-mini -`, &md) - - managed, err := p.loadPromptAgentDefinition() - if err != nil { - t.Fatalf("loadPromptAgentDefinition: %v", err) - } - if managed.Instructions != md { - t.Errorf("instructions: got %q, want %q", managed.Instructions, md) - } -} - -// TestLoadPromptDef_InlineWinsOverFile verifies inline instructions take -// precedence over a sibling instructions.md. -func TestLoadPromptDef_InlineWinsOverFile(t *testing.T) { - md := "FROM FILE" +// TestLoadPromptDef_InlineInstructions verifies instructions are read from the +// inline `instructions:` key, which is the only source the schema supports. +func TestLoadPromptDef_InlineInstructions(t *testing.T) { p := writeAgentYAML(t, ` kind: prompt -name: inline-wins +name: inline-instr model: gpt-4.1-mini instructions: FROM INLINE -`, &md) +`) managed, err := p.loadPromptAgentDefinition() if err != nil { @@ -68,14 +43,14 @@ instructions: FROM INLINE } } -// TestLoadPromptDef_NoInstructionsAnywhere confirms neither inline nor file -// instructions leaves the field empty (graph validation reports the error). -func TestLoadPromptDef_NoInstructionsAnywhere(t *testing.T) { +// TestLoadPromptDef_NoInstructions confirms a manifest without instructions +// loads with an empty field; graph validation is what reports the error. +func TestLoadPromptDef_NoInstructions(t *testing.T) { p := writeAgentYAML(t, ` kind: prompt name: no-instr model: gpt-4.1-mini -`, nil) +`) managed, err := p.loadPromptAgentDefinition() if err != nil { @@ -98,7 +73,7 @@ name: bad model: gpt-4.1-mini instructions: ok `+field+`: something -`, nil) +`) _, err := p.loadPromptAgentDefinition() if err == nil { @@ -120,21 +95,66 @@ func TestResolvePromptAgentGraph_ValidatesModelAndInstructions(t *testing.T) { // Missing model → error. missingModel := &agent_yaml.PromptAgent{Instructions: "ok"} missingModel.Name = "x" - if err := p.resolvePromptAgentGraph(t.Context(), missingModel, nil, nil, nil); err == nil { + if _, err := p.resolvePromptAgentGraph(t.Context(), missingModel, nil, nil, nil); err == nil { t.Error("expected error when model is empty") } // Missing instructions → error. missingInstr := &agent_yaml.PromptAgent{Model: "gpt-4.1-mini"} missingInstr.Name = "x" - if err := p.resolvePromptAgentGraph(t.Context(), missingInstr, nil, nil, nil); err == nil { + if _, err := p.resolvePromptAgentGraph(t.Context(), missingInstr, nil, nil, nil); err == nil { t.Error("expected error when instructions are empty") } // Complete → no error. complete := &agent_yaml.PromptAgent{Model: "gpt-4.1-mini", Instructions: "ok"} complete.Name = "x" - if err := p.resolvePromptAgentGraph(t.Context(), complete, nil, nil, nil); err != nil { + if _, err := p.resolvePromptAgentGraph(t.Context(), complete, nil, nil, nil); err != nil { t.Errorf("unexpected error for complete definition: %v", err) } } + +// TestResolvePromptAgentGraph_HarnessFeatureGate verifies the deploy path +// enforces the harness capability gate: guardrails pass on both a harnessed and +// a plain agent, while knowledge is rejected only when a harness is named. It +// exercises the gate through the graph rather than calling +// ValidateHarnessFeatures directly, so an unwired validation pass would be +// caught. Memory is covered separately because it needs a live endpoint. +func TestResolvePromptAgentGraph_HarnessFeatureGate(t *testing.T) { + p := &AgentServiceTargetProvider{} + + newAgent := func(harness string, tools []any) *agent_yaml.PromptAgent { + agent := &agent_yaml.PromptAgent{ + Model: "gpt-4.1-mini", + Instructions: "ok", + Harness: harness, + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: "/subscriptions/sub/raiPolicies/strict"}, + }, + Tools: tools, + } + agent.Name = "x" + return agent + } + + for _, harness := range []string{"github-copilot", ""} { + agent := newAgent(harness, nil) + if _, err := p.resolvePromptAgentGraph(t.Context(), agent, nil, nil, nil); err != nil { + t.Errorf("harness %q should accept guardrails: %v", harness, err) + } + } + + grounding := []any{map[string]any{"type": "azure_ai_search"}} + + if _, err := p.resolvePromptAgentGraph(t.Context(), newAgent("", grounding), nil, nil, nil); err != nil { + t.Errorf("a plain prompt agent should accept knowledge: %v", err) + } + + _, err := p.resolvePromptAgentGraph(t.Context(), newAgent("github-copilot", grounding), nil, nil, nil) + if err == nil { + t.Fatal("a harnessed agent declaring knowledge should be rejected") + } + if !strings.Contains(err.Error(), "knowledge") { + t.Errorf("error should name the rejected capability, got: %v", err) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment.go index 5bb88159b0d..5e7d5131558 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment.go @@ -6,7 +6,6 @@ package project import ( "context" "fmt" - "os" "strings" "azureaiagent/internal/exterrors" @@ -40,7 +39,7 @@ func deploymentNode( if strings.ContainsAny(model, " /\\") { return exterrors.Validation( exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("model %q is not a valid deployment name", model), + fmt.Sprintf("model deployment name %q is not valid", model), "set 'model' to a model deployment name (e.g. gpt-4.1-mini)", ) } @@ -68,8 +67,8 @@ func deploymentNode( // provisionedDeploymentResolver is the live deploymentResolver. Model // deployments for prompt agents are provisioned by azd infra (recorded at init -// and applied during `azd provision`), so at deploy time the deployment is -// assumed present. This resolver therefore treats every model as existing and +// and applied during `azd provision`), so at deploy time the deployment must +// already exist. This resolver therefore reports every model as existing and // never issues a data-plane create, but keeps the seam so the graph can enforce // the create-if-missing contract in tests and future live wiring. type provisionedDeploymentResolver struct{} @@ -79,10 +78,12 @@ func (provisionedDeploymentResolver) Exists(context.Context, string) (bool, erro } func (provisionedDeploymentResolver) Create(_ context.Context, modelName string) error { - // Should not be reached given Exists always returns true; guard defensively - // with an actionable message rather than a silent no-op. - fmt.Fprintf(os.Stderr, - "Model deployment %q was not found. Provision it with `azd provision` "+ - "(deployments are declared in azure.yaml).\n", modelName) - return nil + // Unreachable while Exists reports true, but returning nil here would let + // the deploy graph continue as though the deployment had been created and + // wire the agent to a model that does not exist. Fail fast instead. + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("model deployment %q was not found", modelName), + "run `azd provision` to create it (model deployments are declared in azure.yaml)", + ) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go index 09fdd085309..b1c433e620d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go @@ -7,20 +7,26 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" + "net/http" "os" "path/filepath" "slices" "strings" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/azure" ) // promptFilesDirName is the conventional folder whose documents are uploaded to -// a vector store backing the agent's file_search tool. -const promptFilesDirName = "files" +// a vector store backing the agent's file_search tool. The name says what the +// folder is for -- these documents become vector-store assets -- rather than the +// generic "files", which reads like miscellaneous project content. +const promptFilesDirName = "vector-assets" // vectorStoreBindingKey is the graph binding under which the resolved vector // store id is published for later nodes / observability. @@ -45,9 +51,9 @@ type vectorStoreBuilder interface { ) (storeID string, err error) } -// scanFilesDir returns the documents under /files, sorted by name. -// Dotfiles and subdirectories are ignored. A missing or empty folder returns -// (nil, nil) so the caller contributes no file_search tool. +// scanFilesDir returns the documents under /vector-assets, sorted by +// name. Dotfiles and subdirectories are ignored. A missing or empty folder +// returns (nil, nil) so the caller contributes no file_search tool. func scanFilesDir(agentDir string) ([]fileEntry, error) { if strings.TrimSpace(agentDir) == "" { return nil, nil @@ -59,12 +65,12 @@ func scanFilesDir(agentDir string) ([]fileEntry, error) { if os.IsNotExist(err) { return nil, nil } - return nil, fmt.Errorf("opening files directory %q: %w", dir, err) + return nil, fmt.Errorf("opening vector asset directory %q: %w", dir, err) } names, err := f.Readdirnames(-1) _ = f.Close() if err != nil { - return nil, fmt.Errorf("reading files directory %q: %w", dir, err) + return nil, fmt.Errorf("reading vector asset directory %q: %w", dir, err) } var entries []fileEntry @@ -73,14 +79,24 @@ func scanFilesDir(agentDir string) ([]fileEntry, error) { continue } full := filepath.Join(dir, name) - info, statErr := os.Stat(full) + // Lstat, not Stat: os.ReadFile follows symlinks, so a link planted under + // vector-assets/ in a cloned agent project would upload whatever it points + // at (local credentials, keys) to the user's Foundry project. + info, statErr := os.Lstat(full) if statErr != nil { return nil, fmt.Errorf("stat %q: %w", full, statErr) } + if info.Mode()&os.ModeSymlink != 0 { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("%q in the %s/ folder is a symbolic link", name, promptFilesDirName), + "replace the link with the file itself; symlinks are not uploaded", + ) + } if info.IsDir() { continue } - content, readErr := os.ReadFile(full) //nolint:gosec // path derived from the agent's files/ folder + content, readErr := os.ReadFile(full) //nolint:gosec // path derived from the agent's vector-assets/ folder if readErr != nil { return nil, fmt.Errorf("reading %q: %w", full, readErr) } @@ -168,7 +184,7 @@ func fileStoreNode( if len(f.Content) == 0 { return exterrors.Validation( exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("file %q in the files/ folder is empty", f.Name), + fmt.Sprintf("file %q in the %s/ folder is empty", f.Name, promptFilesDirName), "remove empty files or add content before deploying", ) } @@ -203,12 +219,52 @@ type foundryVectorStoreBuilder struct { } // EnsureVectorStore uploads any not-yet-uploaded files and creates a vector -// store from the resulting file ids. When reuseStoreID is set it is returned -// as-is after ensuring uploads (add-only update); otherwise a new store is -// created and its id returned. +// store from the resulting file ids. When reuseStoreID is set the files are +// attached to that store (add-only update) and its id is returned, so +// re-deploying an agent does not orphan the previous store and every file +// object it referenced. A reuse id that no longer resolves falls back to +// creating a replacement store. func (b *foundryVectorStoreBuilder) EnsureVectorStore( ctx context.Context, name, reuseStoreID string, files []fileEntry, ) (string, error) { + fileIDs, err := b.resolveFileIDs(ctx, files) + if err != nil { + return "", err + } + + if storeID := strings.TrimSpace(reuseStoreID); storeID != "" { + // Add-only update. Returning the id without attaching the file ids would + // upload every new document and leave it permanently unsearchable. + var attachErr error + for _, id := range fileIDs { + if attachErr = b.client.AddVectorStoreFile(ctx, storeID, id); attachErr != nil { + break + } + } + if attachErr == nil { + return storeID, nil + } + // The recorded store no longer exists (deleted out of band, or the agent + // moved projects). Fall through and mint a replacement rather than + // failing every subsequent deploy. Any other failure is real. + if respErr, ok := errors.AsType[*azcore.ResponseError](attachErr); !ok || + respErr.StatusCode != http.StatusNotFound { + return "", fmt.Errorf("updating vector store %q: %w", storeID, attachErr) + } + } + + store, err := b.client.CreateVectorStore(ctx, name, fileIDs) + if err != nil { + return "", fmt.Errorf("creating vector store: %w", err) + } + return store.Id, nil +} + +// resolveFileIDs returns the Foundry file id for every entry, uploading only +// the ones whose content hash has not already been seen in this deploy. +func (b *foundryVectorStoreBuilder) resolveFileIDs( + ctx context.Context, files []fileEntry, +) ([]string, error) { if b.uploaded == nil { b.uploaded = map[string]string{} } @@ -220,21 +276,12 @@ func (b *foundryVectorStoreBuilder) EnsureVectorStore( } obj, err := b.client.UploadFile(ctx, f.Name, f.Content, "assistants") if err != nil { - return "", fmt.Errorf("uploading %q: %w", f.Name, err) + return nil, fmt.Errorf("uploading %q: %w", f.Name, err) } b.uploaded[f.Hash] = obj.Id fileIDs = append(fileIDs, obj.Id) } - - if strings.TrimSpace(reuseStoreID) != "" { - return reuseStoreID, nil - } - - store, err := b.client.CreateVectorStore(ctx, name, fileIDs) - if err != nil { - return "", fmt.Errorf("creating vector store: %w", err) - } - return store.Id, nil + return fileIDs, nil } // newFoundryVectorStoreBuilder constructs the live builder from prompt settings. @@ -244,7 +291,7 @@ func newFoundryVectorStoreBuilder(settings *PromptAgentSettings) (vectorStoreBui return nil, exterrors.Validation( exterrors.CodeInvalidServiceConfig, "a Foundry project endpoint is required to upload files for file_search", - "run `azd up` to provision a Foundry project, or remove the files/ folder", + "run `azd up` to provision a Foundry project, or remove the "+promptFilesDirName+"/ folder", ) } return &foundryVectorStoreBuilder{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go index e0891a2b5da..7d0a26f7de5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go @@ -39,9 +39,9 @@ func writeFilesDir(t *testing.T, files map[string]string) string { if files == nil { return dir } - filesDir := filepath.Join(dir, "files") + filesDir := filepath.Join(dir, promptFilesDirName) if err := os.MkdirAll(filesDir, 0o750); err != nil { - t.Fatalf("mkdir files: %v", err) + t.Fatalf("mkdir %s: %v", promptFilesDirName, err) } for name, content := range files { if err := os.WriteFile(filepath.Join(filesDir, name), []byte(content), 0o600); err != nil { @@ -52,14 +52,14 @@ func writeFilesDir(t *testing.T, files map[string]string) string { } func TestScanFilesDir_Empty(t *testing.T) { - // Absent files/ folder. + // Absent vector-assets/ folder. dir := writeFilesDir(t, nil) entries, err := scanFilesDir(dir) if err != nil { t.Fatalf("scanFilesDir: %v", err) } if entries != nil { - t.Errorf("expected nil entries for missing files/, got %d", len(entries)) + t.Errorf("expected nil entries for missing %s/, got %d", promptFilesDirName, len(entries)) } } @@ -217,11 +217,11 @@ func TestFoundryVectorStoreBuilder_DedupesByHash(t *testing.T) { {Name: "a.md", Hash: "h1", Content: []byte("a")}, {Name: "b.md", Hash: "h1", Content: []byte("a")}, } - storeID, err := b.EnsureVectorStore(context.Background(), "agent", "vs-existing", files) + ids, err := b.resolveFileIDs(context.Background(), files) if err != nil { - t.Fatalf("EnsureVectorStore: %v", err) + t.Fatalf("resolveFileIDs: %v", err) } - if storeID != "vs-existing" { - t.Errorf("store id: got %q, want reused vs-existing", storeID) + if len(ids) != 2 || ids[0] != "file-1" || ids[1] != "file-1" { + t.Errorf("file ids: got %v, want both file-1", ids) } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go index d12f83585ff..2cd68c869f1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go @@ -21,13 +21,14 @@ import ( type promptNodeKind string const ( - nodeAgent promptNodeKind = "agent" - nodeDeployment promptNodeKind = "deployment" - nodeConnection promptNodeKind = "connection" - nodeRBAC promptNodeKind = "rbac" - nodeFileStore promptNodeKind = "file_store" - nodeSkill promptNodeKind = "skill" - nodeToolbox promptNodeKind = "toolbox" + nodeAgent promptNodeKind = "agent" + nodeDeployment promptNodeKind = "deployment" + nodeConnection promptNodeKind = "connection" + nodeRBAC promptNodeKind = "rbac" + nodeFileStore promptNodeKind = "file_store" + nodeMemoryStore promptNodeKind = "memory_store" + nodeSkill promptNodeKind = "skill" + nodeToolbox promptNodeKind = "toolbox" ) // promptNode is a single dependency in the prompt-agent deploy graph. Validate @@ -48,7 +49,7 @@ type promptNode struct { // of this machinery is exposed in the YAML. type promptGraph struct { // agentDir is the folder holding agent.yaml plus any convention folders - // (instructions.md, files/, skills/). + // (vector-assets/, skills/). agentDir string // managed is the parsed agent definition. Nodes may enrich managed.Tools @@ -65,10 +66,36 @@ type promptGraph struct { // "vector_store_id" or "toolbox_mcp_url") that later nodes read. bindings map[string]any + // warn reports a non-fatal finding to the user. It is set for the duration + // of resolve and is nil otherwise, so nodes must go through warnf. + // + // A dedicated channel exists because the extension's stderr is not forwarded + // to the azd console: anything not routed through the progress reporter is + // invisible during a deploy. + warn func(string) + // nodes is the ordered set of dependencies to validate and resolve. nodes []promptNode } +// warnf reports a non-fatal finding discovered while resolving the graph. +// No-ops when the graph is not being resolved through resolve (e.g. in tests). +func (g *promptGraph) warnf(format string, args ...any) { + if g.warn == nil { + return + } + g.warn(fmt.Sprintf(format, args...)) +} + +// pluralize appends "s" to noun when count is not 1, so warning text reads +// naturally for both a single finding and several. +func pluralize(noun string, count int) string { + if count == 1 { + return noun + } + return noun + "s" +} + // newPromptGraph builds a graph for the given agent. Only the agent node is // registered today; file/skill/connection nodes are added by later stages. func newPromptGraph( @@ -93,8 +120,8 @@ func newPromptGraph( g.nodes = append(g.nodes, *node) } - // Convention: a non-empty files/ folder contributes a file_search tool - // backed by an uploaded vector store. + // Convention: a non-empty vector-assets/ folder contributes a file_search + // tool backed by an uploaded vector store. files, err := scanFilesDir(agentDir) if err != nil { return nil, err @@ -105,16 +132,44 @@ func newPromptGraph( g.nodes = append(g.nodes, *node) } - // Convention: a non-empty skills/ folder (or an explicit toolbox reference) - // contributes an mcp tool backed by a Foundry toolbox version. + // A declared memory: block provisions a memory store and contributes the + // memory_search_preview tool that reads from it. + if node := memoryNode(g, managed.Memory, func() (memoryStoreEnsurer, error) { + return newFoundryMemoryStoreEnsurer(settings) + }); node != nil { + g.nodes = append(g.nodes, *node) + } + + // Convention: a non-empty skills/ folder contributes the agent's skills. + // How they are reached splits on the harness — a managed agent provisions + // them into its sandbox by pinning them on the harness block, while a plain + // prompt agent references them by name and runs them with a shell tool. skills, err := scanSkillsDir(agentDir) if err != nil { return nil, err } - if node := toolboxNode(g, skills, managed.Toolbox, func() (toolboxBuilder, error) { - return newFoundryToolboxBuilder(settings) - }); node != nil { - g.nodes = append(g.nodes, *node) + if strings.TrimSpace(managed.Harness) != "" { + // An explicit toolbox: reference is a separate feature from skills: it + // attaches an existing shared toolbox as an mcp tool. Skills are never + // routed through a toolbox of azd's making — the harness already has a + // service-owned system toolbox whose name, version and lifecycle the + // customer does not manage. + if node := toolboxNode(g, managed.Toolbox, func() (toolboxBuilder, error) { + return newFoundryToolboxBuilder(settings) + }); node != nil { + g.nodes = append(g.nodes, *node) + } + if node := skillsHarnessNode(g, skills, func() (harnessSkillPublisher, error) { + return newFoundrySkillPublisher(settings) + }); node != nil { + g.nodes = append(g.nodes, *node) + } + } else { + if node := skillsShellNode(g, skills, managed.Toolbox, func() (skillAttacher, error) { + return newFoundrySkillPublisher(settings) + }); node != nil { + g.nodes = append(g.nodes, *node) + } } // Declared connections are resolved last among the feature stages: existing @@ -144,14 +199,66 @@ func (g *promptGraph) agentNode() promptNode { return exterrors.Validation( exterrors.CodeInvalidAgentManifest, "prompt agent requires a non-empty model", - "set 'model' in agent.yaml (e.g. model: gpt-4.1-mini)", + "set 'model' in agent.yaml to the name of a deployment "+ + "declared under your azure.ai.project service (e.g. model: gpt-4.1-mini)", ) } if strings.TrimSpace(g.managed.Instructions) == "" { return exterrors.Validation( exterrors.CodeInvalidAgentManifest, "prompt agent requires non-empty instructions", - "set 'instructions' in agent.yaml or add a sibling instructions.md", + "set 'instructions' in agent.yaml", + ) + } + if err := g.managed.ValidateHarnessFeatures(); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + err.Error(), + "remove that configuration from agent.yaml, or drop 'harness:' to run as a "+ + "plain prompt agent, which supports it", + ) + } + // A tool the service cannot identify is dropped silently, producing an + // agent that is missing a capability its manifest claims. Catch the + // unambiguous cases before anything is provisioned. + if err := g.managed.ValidateTools(); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + err.Error(), + "each entry under 'tools:' must be a mapping with a string 'type', "+ + "for example '- type: file_search'", + ) + } + // A harness owns sampling, response format and tool dispatch. The + // service rejects a manifest that sets them rather than ignoring it, + // so name the offending key before anything is provisioned. + if err := g.managed.ValidateHarnessFields(); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + err.Error(), + "remove that field from agent.yaml, or drop 'harness:' to run as a "+ + "plain prompt agent, which accepts it", + ) + } + if err := g.managed.ValidateHarnessTools(); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + err.Error(), + "remove that tool from agent.yaml, or drop 'harness:' to run as a "+ + "plain prompt agent, which supports it", + ) + } + // A bare RAI policy name reaches the service as "invalid or does not + // exist", which reads like a missing policy rather than a malformed + // value. Catch the shape here so the message points at the right fix. + if err := g.managed.ValidatePolicies(); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + err.Error(), + "list the policy IDs on your account with: az rest --method get --url "+ + "\"https://management.azure.com/subscriptions//resourceGroups//"+ + "providers/Microsoft.CognitiveServices/accounts//"+ + "raiPolicies?api-version=2024-10-01\" --query \"value[].id\" -o tsv", ) } return nil @@ -164,6 +271,11 @@ func (g *promptGraph) agentNode() promptNode { // order. Validation runs to completion before any Resolve so a failure never // leaves a half-wired agent. func (g *promptGraph) resolve(ctx context.Context, progress azdext.ProgressReporter) error { + if progress != nil { + g.warn = func(message string) { progress("Warning: " + message) } + defer func() { g.warn = nil }() + } + // Surface which convention nodes were discovered via the progress reporter // (the extension's stderr is not forwarded to the azd console, so this is // the only reliable way to report it during a deploy). @@ -184,6 +296,18 @@ func (g *promptGraph) resolve(ctx context.Context, progress azdext.ProgressRepor } } + // Reported after validation and before any node injects its own tools, so + // the list only ever names types the author actually wrote. + if unrecognized := g.managed.UnrecognizedToolTypes(); len(unrecognized) > 0 { + g.warnf( + "agent.yaml declares unrecognized tool %s: %s. "+ + "These are sent as authored, but a type the service does not recognize is ignored "+ + "without error \u2014 check the spelling if the capability does not appear.", + pluralize("type", len(unrecognized)), + strings.Join(unrecognized, ", "), + ) + } + for _, n := range g.nodes { if n.Resolve == nil { continue @@ -202,20 +326,34 @@ func (g *promptGraph) resolve(ctx context.Context, progress azdext.ProgressRepor // resolvePromptAgentGraph builds and resolves the deploy graph for a prompt // agent. It is called by deployPromptAgent before the create request is built, // so any resolved bindings are reflected in the published agent definition. +// The resolved bindings are returned so the caller can persist ids (such as the +// vector store id) that must survive into the next deploy. func (p *AgentServiceTargetProvider) resolvePromptAgentGraph( ctx context.Context, managed *agent_yaml.PromptAgent, settings *PromptAgentSettings, env map[string]string, progress azdext.ProgressReporter, -) error { +) (map[string]any, error) { agentDir := "" if p.agentDefinitionPath != "" { agentDir = filepath.Dir(p.agentDefinitionPath) } g, err := newPromptGraph(agentDir, managed, settings, env) if err != nil { - return err + return nil, err + } + // Seed the vector store binding from the previous deploy. Without it the + // file-store node always mints a new store, orphaning the old store and + // every file object it referenced on every single deploy. + if p.serviceConfig != nil { + key := fmt.Sprintf("AGENT_%s_VECTOR_STORE_ID", p.getServiceKey(p.serviceConfig.Name)) + if storeID := strings.TrimSpace(env[key]); storeID != "" { + g.bindings[vectorStoreBindingKey] = storeID + } + } + if err := g.resolve(ctx, progress); err != nil { + return nil, err } - return g.resolve(ctx, progress) + return g.bindings, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph_warnings_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph_warnings_test.go new file mode 100644 index 00000000000..398729506aa --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph_warnings_test.go @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + + "github.com/stretchr/testify/require" +) + +// captureWarnings wires a warning sink onto the graph and returns the collected +// messages. +func captureWarnings(g *promptGraph) *[]string { + var warnings []string + g.warn = func(message string) { warnings = append(warnings, message) } + return &warnings +} + +// TestMemoryNode_ReportsDrift covers the case that motivated the check: the +// manifest is edited, the store already exists, and the edit therefore does +// nothing. The deploy still succeeds, so without a warning the manifest and the +// live resource disagree silently and forever. +func TestMemoryNode_ReportsDrift(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + declared agent_yaml.PromptMemory + live azure.MemoryStoreDefinition + wantWarning []string + }{ + { + name: "chat model drifted", + declared: agent_yaml.PromptMemory{ + Store: "m", ChatModel: "gpt-4.1", EmbeddingModel: "text-embedding-3-small", + }, + live: azure.MemoryStoreDefinition{ + ChatModel: "gpt-4o", EmbeddingModel: "text-embedding-3-small", + }, + wantWarning: []string{`chat_model (declared "gpt-4.1", current "gpt-4o")`}, + }, + { + name: "both drifted", + declared: agent_yaml.PromptMemory{ + Store: "m", ChatModel: "gpt-4.1", EmbeddingModel: "text-embedding-3-large", + }, + live: azure.MemoryStoreDefinition{ + ChatModel: "gpt-4o", EmbeddingModel: "text-embedding-3-small", + }, + wantWarning: []string{ + `chat_model (declared "gpt-4.1", current "gpt-4o")`, + `embedding_model (declared "text-embedding-3-large", current "text-embedding-3-small")`, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + memory := test.declared + g, fake, node := newMemoryTestGraph(&memory) + warnings := captureWarnings(g) + + // created=false is what fakeMemoryStoreEnsurer returns when a store + // is pre-seeded, which is exactly the reuse path under test. + fake.store = &azure.MemoryStoreObject{ + Name: memory.Store, Id: "store-1", Definition: test.live, + } + + require.NoError(t, node.Resolve(t.Context())) + + require.Len(t, *warnings, 1) + for _, fragment := range test.wantWarning { + require.Contains(t, (*warnings)[0], fragment) + } + require.Contains(t, (*warnings)[0], "never updated") + + // The drift is reported, not enforced: the tool is still wired up so + // the deploy produces a working agent. + require.Equal(t, memory.Store, g.bindings[memoryStoreBindingKey]) + }) + } +} + +// TestMemoryNode_NoDriftWarning verifies the check stays quiet when it should, +// so the warning keeps its signal. +func TestMemoryNode_NoDriftWarning(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + live azure.MemoryStoreDefinition + }{ + { + name: "definitions match", + live: azure.MemoryStoreDefinition{ChatModel: "gpt-4.1", EmbeddingModel: "embed"}, + }, + { + // A service that does not echo the definition back is not evidence + // of drift, and treating it as such would warn on every deploy. + name: "service returned no definition", + live: azure.MemoryStoreDefinition{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + memory := agent_yaml.PromptMemory{ + Store: "m", ChatModel: "gpt-4.1", EmbeddingModel: "embed", + } + g, fake, node := newMemoryTestGraph(&memory) + warnings := captureWarnings(g) + fake.store = &azure.MemoryStoreObject{Name: "m", Definition: test.live} + + require.NoError(t, node.Resolve(t.Context())) + require.Empty(t, *warnings) + }) + } +} + +// TestMemoryNode_NewStoreNeverWarnsDrift verifies a freshly created store is not +// compared against itself. +func TestMemoryNode_NewStoreNeverWarnsDrift(t *testing.T) { + t.Parallel() + + memory := agent_yaml.PromptMemory{Store: "m", ChatModel: "gpt-4.1", EmbeddingModel: "embed"} + g, _, node := newMemoryTestGraph(&memory) + warnings := captureWarnings(g) + + // The fake reports created=true with an empty definition when no store is + // pre-seeded, which would look like drift if creation were not excluded. + require.NoError(t, node.Resolve(t.Context())) + require.Empty(t, *warnings) +} + +// TestPromptGraph_WarnfIsSafeWithoutSink verifies warnings outside a resolve are +// dropped rather than panicking. +func TestPromptGraph_WarnfIsSafeWithoutSink(t *testing.T) { + t.Parallel() + + g := &promptGraph{bindings: map[string]any{}} + require.NotPanics(t, func() { g.warnf("anything %s", "here") }) +} + +// TestAgentNode_RejectsMalformedTools verifies tool validation runs in the +// validate phase, before anything is provisioned. +func TestAgentNode_RejectsMalformedTools(t *testing.T) { + t.Parallel() + + managed := &agent_yaml.PromptAgent{ + Model: "gpt-4.1-mini", + Instructions: "You are helpful.", + Tools: []any{map[string]any{"server_label": "toolbox"}}, + } + managed.Name = "agent-1" + + g := &promptGraph{managed: managed, bindings: map[string]any{}} + err := g.agentNode().Validate() + + require.Error(t, err) + require.Contains(t, err.Error(), "missing a 'type' key") +} + +// TestAgentNode_AllowsUnrecognizedToolType verifies an unfamiliar tool type is +// not a hard failure. `tools:` is pass-through so authors can use service +// features newer than their azd build; failing here would make every new tool +// type a breaking change. +func TestAgentNode_AllowsUnrecognizedToolType(t *testing.T) { + t.Parallel() + + managed := &agent_yaml.PromptAgent{ + Model: "gpt-4.1-mini", + Instructions: "You are helpful.", + Tools: []any{map[string]any{"type": "something_new_preview"}}, + } + managed.Name = "agent-1" + + g := &promptGraph{managed: managed, bindings: map[string]any{}} + require.NoError(t, g.agentNode().Validate()) + + unrecognized := managed.UnrecognizedToolTypes() + require.Equal(t, []string{"something_new_preview"}, unrecognized) +} + +func TestPluralize(t *testing.T) { + t.Parallel() + + require.Equal(t, "type", pluralize("type", 1)) + require.Equal(t, "types", pluralize("type", 2)) + require.Equal(t, "types", pluralize("type", 0)) +} + +// TestPromptGraph_WarnsOnUnrecognizedToolTypes exercises the warning end to end +// through resolve, including that it reaches the progress reporter. +func TestPromptGraph_WarnsOnUnrecognizedToolTypes(t *testing.T) { + t.Parallel() + + managed := &agent_yaml.PromptAgent{ + Model: "gpt-4.1-mini", + Instructions: "You are helpful.", + Tools: []any{ + map[string]any{"type": "file_search"}, + map[string]any{"type": "file_serach"}, + }, + } + managed.Name = "agent-1" + + g := &promptGraph{managed: managed, bindings: map[string]any{}} + g.nodes = append(g.nodes, g.agentNode()) + + var messages []string + require.NoError(t, g.resolve(t.Context(), func(message string) { messages = append(messages, message) })) + + joined := strings.Join(messages, "\n") + require.Contains(t, joined, "Warning:") + require.Contains(t, joined, "file_serach") + require.NotContains(t, joined, "file_search,", "the correctly spelled tool should not be flagged") + + // The sink is cleared once resolve returns, so a later stray warning cannot + // be attributed to a deploy that already finished. + require.Nil(t, g.warn) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_memory.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_memory.go new file mode 100644 index 00000000000..3ea748dcd1b --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_memory.go @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" +) + +// memorySearchToolType is the wire `type` of the tool that lets an agent recall +// from a memory store. The `_preview` suffix is part of the contract, not a +// description of it: the API previously defined a plain "memory_search" type +// and removed it in v1, so dropping the suffix names a type the service no +// longer recognizes — and an unrecognized tool is ignored without error. +const memorySearchToolType = "memory_search_preview" + +// memoryStoreBindingKey is the graph binding under which the resolved memory +// store name is published for later nodes / observability. +const memoryStoreBindingKey = "memory_store_name" + +// memoryStoreEnsurer creates a memory store if it does not already exist and +// returns the live store. Implementations are idempotent. The seam keeps the +// graph node unit-testable without a live endpoint. +type memoryStoreEnsurer interface { + EnsureMemoryStore( + ctx context.Context, request *azure.CreateMemoryStoreRequest, + ) (store *azure.MemoryStoreObject, created bool, err error) +} + +// memoryNode builds the memory_store graph node for a declared `memory:` block. +// It ensures the store exists and then injects the memory_search_preview tool +// that actually connects the agent to it. Returns nil when no memory is +// declared (the caller then registers no node). +// +// Both halves live in one node on purpose. The store and the tool are useless +// apart — a store nothing reads from, or a tool pointing at a store that does +// not exist — so they succeed or fail together. +func memoryNode( + g *promptGraph, + memory *agent_yaml.PromptMemory, + newEnsurer func() (memoryStoreEnsurer, error), +) *promptNode { + if memory == nil { + return nil + } + return &promptNode{ + Kind: nodeMemoryStore, + ID: strings.TrimSpace(memory.Store), + Validate: func() error { + if strings.TrimSpace(memory.Store) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "memory requires a store name", + "set 'memory.store' in agent.yaml to the name of the memory store to use "+ + "(e.g. store: conversation-memory)", + ) + } + // The store is created if missing, and creation needs both models. + // Requiring them up front beats discovering it mid-deploy, after the + // model deployment and vector store have already been provisioned. + missing := make([]string, 0, 2) + if strings.TrimSpace(memory.ChatModel) == "" { + missing = append(missing, "memory.chat_model") + } + if strings.TrimSpace(memory.EmbeddingModel) == "" { + missing = append(missing, "memory.embedding_model") + } + if len(missing) > 0 { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("memory store %q requires %s", memory.Store, strings.Join(missing, " and ")), + "set them to model deployment names declared under your azure.ai.project service; "+ + "the chat model summarizes conversations and the embedding model indexes memories", + ) + } + return nil + }, + Resolve: func(ctx context.Context) error { + ensurer, err := newEnsurer() + if err != nil { + return err + } + + store, created, err := ensurer.EnsureMemoryStore(ctx, memoryStoreRequest(memory)) + if err != nil { + return fmt.Errorf("ensuring memory store %q: %w", memory.Store, err) + } + + // Prefer the service's name over the declared one so the tool + // references the store as the service actually recorded it. + name := strings.TrimSpace(store.Name) + if name == "" { + name = strings.TrimSpace(memory.Store) + } + + if !created { + reportMemoryStoreDrift(g, name, memory, store) + } + + g.bindings[memoryStoreBindingKey] = name + injectMemorySearchTool(g.managed, name, memory) + return nil + }, + } +} + +// reportMemoryStoreDrift warns when a reused store's live definition differs +// from what agent.yaml declares. +// +// Memory stores are created-if-missing and never updated, so editing +// memory.chat_model in a manifest whose store already exists has no effect. The +// deploy still succeeds, which is the problem: without this, the manifest and +// the resource disagree silently and indefinitely. Warning rather than failing +// keeps a store shared with another agent — whose definition this manifest does +// not own — from blocking the deploy. +// +// The comparison is shared with the azure.yaml memoryStores: path. agent.yaml +// keys match the wire field paths, so no label mapping is needed. +func reportMemoryStoreDrift( + g *promptGraph, + storeName string, + declared *agent_yaml.PromptMemory, + live *azure.MemoryStoreObject, +) { + drifted := describeMemoryStoreDrift( + diffMemoryStoreDefinition(memoryStoreDefinition(declared), live.Definition), + nil, + ) + if len(drifted) == 0 { + return + } + + g.warnf( + "memory store %q already exists and its %s. "+ + "Existing stores are reused as-is and never updated, so the declared value has no effect. "+ + "Delete the store, or point 'memory.store' at a new name, to apply it.", + storeName, + strings.Join(drifted, "; and its "), + ) +} + +// memoryStoreRequest translates the authored memory block into the create +// request for the memory store resource. +func memoryStoreRequest(memory *agent_yaml.PromptMemory) *azure.CreateMemoryStoreRequest { + return &azure.CreateMemoryStoreRequest{ + Name: strings.TrimSpace(memory.Store), + Description: memory.Description, + Definition: memoryStoreDefinition(memory), + } +} + +// memoryStoreDefinition translates the authored memory block into the wire +// definition. It is split out from memoryStoreRequest so the drift check +// compares the exact definition that creation would have sent, rather than a +// second, hand-maintained projection of the same fields. +func memoryStoreDefinition(memory *agent_yaml.PromptMemory) azure.MemoryStoreDefinition { + definition := azure.MemoryStoreDefinition{ + Kind: azure.MemoryStoreKindDefault, + ChatModel: strings.TrimSpace(memory.ChatModel), + EmbeddingModel: strings.TrimSpace(memory.EmbeddingModel), + } + + if memory.Options != nil { + definition.Options = memoryStoreOptionsOrNil(&azure.MemoryStoreOptions{ + ChatSummaryEnabled: memory.Options.ChatSummaryEnabled, + UserProfileEnabled: memory.Options.UserProfileEnabled, + ProceduralMemoryEnabled: memory.Options.ProceduralMemoryEnabled, + DefaultTTLSeconds: memory.Options.DefaultTTLSeconds, + UserProfileDetails: memory.Options.UserProfileDetails, + }) + } + + return definition +} + +// injectMemorySearchTool ensures the agent's tools include a +// memory_search_preview tool bound to storeName. An existing entry is updated in +// place rather than duplicated, so re-deploying does not accumulate tools. The +// managed definition is mutated in place. +func injectMemorySearchTool(managed *agent_yaml.PromptAgent, storeName string, memory *agent_yaml.PromptMemory) { + if managed == nil || memory == nil || strings.TrimSpace(storeName) == "" { + return + } + + scope := strings.TrimSpace(memory.Scope) + if scope == "" { + // Default to per-caller isolation. A shared default would let one user's + // memories surface in another user's conversation. + scope = agent_yaml.DefaultMemoryScope + } + + tool := map[string]any{ + "type": memorySearchToolType, + "memory_store_name": storeName, + "scope": scope, + } + if memory.UpdateDelay != nil { + tool["update_delay"] = *memory.UpdateDelay + } + if memory.MaxMemories != nil { + tool["search_options"] = map[string]any{"max_memories": *memory.MaxMemories} + } + + for i, raw := range managed.Tools { + existing, ok := raw.(map[string]any) + if !ok { + continue + } + if fmt.Sprintf("%v", existing["type"]) != memorySearchToolType { + continue + } + managed.Tools[i] = tool + return + } + + managed.Tools = append(managed.Tools, tool) +} + +// newFoundryMemoryStoreEnsurer constructs the live ensurer from prompt settings. +// It requires a resolved project endpoint (data-plane) to reach the memory +// stores API. +func newFoundryMemoryStoreEnsurer(settings *PromptAgentSettings) (memoryStoreEnsurer, error) { + if settings == nil || strings.TrimSpace(settings.ProjectEndpoint) == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "a Foundry project endpoint is required to provision a memory store", + "run `azd up` to provision a Foundry project, or remove the 'memory:' block from agent.yaml", + ) + } + return azure.NewFoundryMemoryStoreClient(settings.ProjectEndpoint, promptCredential()), nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_memory_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_memory_test.go new file mode 100644 index 00000000000..fb314fd2007 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_memory_test.go @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + + "github.com/stretchr/testify/require" +) + +// fakeMemoryStoreEnsurer records the request it was handed so tests can assert +// what azd would send, without a live Foundry endpoint. +type fakeMemoryStoreEnsurer struct { + request *azure.CreateMemoryStoreRequest + store *azure.MemoryStoreObject + err error +} + +func (f *fakeMemoryStoreEnsurer) EnsureMemoryStore( + _ context.Context, request *azure.CreateMemoryStoreRequest, +) (*azure.MemoryStoreObject, bool, error) { + f.request = request + if f.err != nil { + return nil, false, f.err + } + if f.store != nil { + return f.store, false, nil + } + return &azure.MemoryStoreObject{Name: request.Name, Id: "store-1"}, true, nil +} + +func newMemoryTestGraph(memory *agent_yaml.PromptMemory) (*promptGraph, *fakeMemoryStoreEnsurer, *promptNode) { + managed := &agent_yaml.PromptAgent{Memory: memory} + managed.Name = "agent-1" + + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeMemoryStoreEnsurer{} + node := memoryNode(g, memory, func() (memoryStoreEnsurer, error) { return fake, nil }) + return g, fake, node +} + +// TestMemoryNode_NotRegisteredWithoutMemory verifies no node (and therefore no +// store provisioning) happens for an agent that declares no memory. +func TestMemoryNode_NotRegisteredWithoutMemory(t *testing.T) { + t.Parallel() + + _, _, node := newMemoryTestGraph(nil) + require.Nil(t, node) +} + +// TestMemoryNode_Validate covers the required fields. Validation runs before any +// node resolves, so catching these here means a misconfigured memory block never +// gets as far as provisioning a model deployment or a vector store. +func TestMemoryNode_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + memory agent_yaml.PromptMemory + wantErr string + }{ + { + name: "missing store name", + memory: agent_yaml.PromptMemory{ChatModel: "c", EmbeddingModel: "e"}, + wantErr: "memory requires a store name", + }, + { + name: "missing chat model", + memory: agent_yaml.PromptMemory{Store: "s", EmbeddingModel: "e"}, + wantErr: "memory.chat_model", + }, + { + name: "missing embedding model", + memory: agent_yaml.PromptMemory{Store: "s", ChatModel: "c"}, + wantErr: "memory.embedding_model", + }, + { + name: "complete", + memory: agent_yaml.PromptMemory{Store: "s", ChatModel: "c", EmbeddingModel: "e"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, _, node := newMemoryTestGraph(&tc.memory) + require.NotNil(t, node) + + err := node.Validate() + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tc.wantErr) + }) + } +} + +// TestMemoryNode_ResolveInjectsTool is the core assertion of the feature: the +// prompt-agent API has no memory field, so memory only works if resolving the +// node adds a memory_search_preview tool pointing at the provisioned store. +func TestMemoryNode_ResolveInjectsTool(t *testing.T) { + t.Parallel() + + updateDelay := 300 + maxMemories := 5 + enabled := true + + memory := &agent_yaml.PromptMemory{ + Store: "support-memory", + Description: "Support conversations", + ChatModel: "gpt-4.1-mini", + EmbeddingModel: "text-embedding-3-small", + Scope: "user_123", + UpdateDelay: &updateDelay, + MaxMemories: &maxMemories, + Options: &agent_yaml.PromptMemoryOptions{UserProfileEnabled: &enabled}, + } + + g, fake, node := newMemoryTestGraph(memory) + require.NoError(t, node.Resolve(t.Context())) + + // The store is created from the declared models. + require.Equal(t, "support-memory", fake.request.Name) + require.Equal(t, "Support conversations", fake.request.Description) + require.Equal(t, azure.MemoryStoreKindDefault, fake.request.Definition.Kind) + require.Equal(t, "gpt-4.1-mini", fake.request.Definition.ChatModel) + require.Equal(t, "text-embedding-3-small", fake.request.Definition.EmbeddingModel) + require.NotNil(t, fake.request.Definition.Options) + require.True(t, *fake.request.Definition.Options.UserProfileEnabled) + + require.Equal(t, "support-memory", g.bindings[memoryStoreBindingKey]) + + require.Len(t, g.managed.Tools, 1) + tool, ok := g.managed.Tools[0].(map[string]any) + require.True(t, ok, "tool: got %T", g.managed.Tools[0]) + + require.Equal(t, "memory_search_preview", tool["type"]) + require.Equal(t, "support-memory", tool["memory_store_name"]) + require.Equal(t, "user_123", tool["scope"]) + require.Equal(t, 300, tool["update_delay"]) + require.Equal(t, map[string]any{"max_memories": 5}, tool["search_options"]) +} + +// TestMemoryNode_ResolveDefaultsScope verifies an unset scope falls back to the +// per-caller default. A shared default would let one user's memories surface in +// another user's conversation, so this is a privacy boundary, not a nicety. +func TestMemoryNode_ResolveDefaultsScope(t *testing.T) { + t.Parallel() + + memory := &agent_yaml.PromptMemory{Store: "s", ChatModel: "c", EmbeddingModel: "e"} + g, _, node := newMemoryTestGraph(memory) + require.NoError(t, node.Resolve(t.Context())) + + tool, ok := g.managed.Tools[0].(map[string]any) + require.True(t, ok, "tool: got %T", g.managed.Tools[0]) + require.Equal(t, agent_yaml.DefaultMemoryScope, tool["scope"]) + + // Optional knobs stay absent so the service applies its own defaults rather + // than azd pinning them to a zero value. + require.NotContains(t, tool, "update_delay") + require.NotContains(t, tool, "search_options") +} + +// TestMemoryNode_ResolveReplacesExistingTool verifies re-deploying updates the +// existing tool in place. Appending instead would accumulate a duplicate +// memory_search_preview entry on every deploy. +func TestMemoryNode_ResolveReplacesExistingTool(t *testing.T) { + t.Parallel() + + memory := &agent_yaml.PromptMemory{Store: "new-store", ChatModel: "c", EmbeddingModel: "e"} + g, _, node := newMemoryTestGraph(memory) + g.managed.Tools = []any{ + map[string]any{"type": "code_interpreter"}, + map[string]any{"type": "memory_search_preview", "memory_store_name": "old-store"}, + } + + require.NoError(t, node.Resolve(t.Context())) + + require.Len(t, g.managed.Tools, 2) + tool, ok := g.managed.Tools[1].(map[string]any) + require.True(t, ok, "tool: got %T", g.managed.Tools[1]) + require.Equal(t, "new-store", tool["memory_store_name"]) +} + +// TestMemoryNode_ResolveWrapsError verifies a provisioning failure names the +// store, so the user knows which resource to look at. +func TestMemoryNode_ResolveWrapsError(t *testing.T) { + t.Parallel() + + memory := &agent_yaml.PromptMemory{Store: "support-memory", ChatModel: "c", EmbeddingModel: "e"} + managed := &agent_yaml.PromptAgent{Memory: memory} + managed.Name = "agent-1" + + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeMemoryStoreEnsurer{err: errors.New("boom")} + node := memoryNode(g, memory, func() (memoryStoreEnsurer, error) { return fake, nil }) + + err := node.Resolve(t.Context()) + require.ErrorContains(t, err, "support-memory") + require.ErrorContains(t, err, "boom") + require.Empty(t, g.managed.Tools, "no tool should be injected when the store fails") +} + +// TestNewFoundryMemoryStoreEnsurer_RequiresEndpoint verifies the live ensurer +// refuses to build without a project endpoint rather than failing later with an +// opaque request error. +func TestNewFoundryMemoryStoreEnsurer_RequiresEndpoint(t *testing.T) { + t.Parallel() + + _, err := newFoundryMemoryStoreEnsurer(nil) + require.ErrorContains(t, err, "project endpoint") + + _, err = newFoundryMemoryStoreEnsurer(&PromptAgentSettings{}) + require.ErrorContains(t, err, "project endpoint") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go index b957c42bbe7..2d40b90f374 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go @@ -66,19 +66,34 @@ type toolboxAttachment struct { ConnectionName string } -// toolboxBuilder registers skills into a toolbox version (primary path) or -// resolves an existing toolbox (reference path), returning the toolbox MCP url -// and the project connection that fronts it. The seam keeps the graph node -// unit-testable without a live endpoint. +// toolboxBuilder resolves an existing toolbox named by an explicit `toolbox:` +// reference, returning the toolbox MCP url and the project connection that +// fronts it. The seam keeps the graph node unit-testable without a live +// endpoint. +// +// There is deliberately no "create a toolbox" operation here. Every harnessed +// agent already has a system toolbox that the service creates, versions and +// deletes with the agent, and whose name customers never supply. type toolboxBuilder interface { - // EnsureToolbox registers the skills into a toolbox named toolboxName and - // returns its MCP url and backing project connection. - EnsureToolbox(ctx context.Context, toolboxName string, skills []skillBundle) (toolboxAttachment, error) // ResolveToolbox returns the MCP url and backing project connection of an // existing toolbox version. ResolveToolbox(ctx context.Context, ref toolboxRef) (toolboxAttachment, error) } +// skillAttacher publishes skill bundles for a harness-less prompt agent, which +// reaches them through a shell tool instead of a toolbox. It returns the +// registered skill names. Same seam purpose as toolboxBuilder. +type skillAttacher interface { + AttachSkills(ctx context.Context, skills []skillBundle) ([]string, error) +} + +// harnessSkillPublisher publishes skill bundles for a harnessed agent. It +// returns the resolved name and version of each, because a harness skill +// reference has to pin a version. +type harnessSkillPublisher interface { + PublishSkills(ctx context.Context, skills []skillBundle) ([]publishedSkill, error) +} + // scanSkillsDir returns the skill bundles under /skills, one per // subfolder, sorted by name. Each bundle's SKILL.md is parsed. A missing or // empty folder returns (nil, nil). @@ -107,10 +122,19 @@ func scanSkillsDir(agentDir string) ([]skillBundle, error) { continue } bundleDir := filepath.Join(dir, name) - info, statErr := os.Stat(bundleDir) + // Lstat, not Stat: a symlinked bundle would let a cloned agent project + // package and upload files from anywhere on the developer's machine. + info, statErr := os.Lstat(bundleDir) if statErr != nil { return nil, fmt.Errorf("stat %q: %w", bundleDir, statErr) } + if info.Mode()&os.ModeSymlink != 0 { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("skill bundle %q is a symbolic link", name), + "replace the link with the skill folder itself; symlinks are not packaged", + ) + } if !info.IsDir() { continue } @@ -202,16 +226,22 @@ func extractFrontmatter(content string) (frontmatterResult, error) { // Drop the opening delimiter line. rest := trimmed[len("---"):] rest = strings.TrimLeft(rest, "\r\n") - end := strings.Index(rest, "\n---") - if end < 0 { + before, after, ok := strings.Cut(rest, "\n---") + if !ok { return frontmatterResult{}, fmt.Errorf("unterminated frontmatter block") } - front := rest[:end] - // The body starts after the closing `---` line. - after := rest[end+len("\n---"):] - after = strings.TrimPrefix(after, "-") // tolerate longer --- fences - after = strings.TrimLeft(after, "-\r\n") // consume the rest of the fence line - return frontmatterResult{frontmatter: front, body: strings.TrimLeft(after, "\r\n")}, nil + front := before + // The body starts after the closing `---` line. Consume only the remainder of + // that fence line (extra dashes from a longer `-----` fence plus trailing + // whitespace) and stop at its newline. A cut set mixing "-" with newlines + // would cross into the body and strip the leading dash from a `- bullet` or + // a `---` break on the body's first line. + if nl := strings.IndexByte(after, '\n'); nl >= 0 { + after = after[nl+1:] + } else { + after = "" + } + return frontmatterResult{frontmatter: front, body: after}, nil } // injectMcpTool ensures the agent's tools include an mcp tool for the given @@ -255,26 +285,115 @@ func injectMcpTool(managed *agent_yaml.PromptAgent, serverLabel, mcpURL, connect managed.Tools = append(managed.Tools, mcpTool) } -// toolboxNode builds the skill/toolbox graph node. When ref is non-nil the -// existing toolbox is attached by reference; otherwise the skill bundles are -// registered into a new toolbox version. Returns nil when there is nothing to -// attach (no skills and no reference). -func toolboxNode( +// promptSkillShellToolType is the tool a harness-less prompt agent uses to run +// its skills. A skill bundle is files plus a script, so the agent needs shell +// execution to invoke one; a managed agent gets the equivalent from its harness +// sandbox and reaches skills through a toolbox instead. +const promptSkillShellToolType = "shell" + +// injectShellTool ensures the agent's tools include a shell tool, so published +// skills are actually runnable. An existing shell tool is left in place. The +// definition is mutated in place. +func injectShellTool(managed *agent_yaml.PromptAgent) { + if managed == nil { + return + } + for _, raw := range managed.Tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if fmt.Sprintf("%v", tool["type"]) == promptSkillShellToolType { + return + } + } + managed.Tools = append(managed.Tools, map[string]any{ + "type": promptSkillShellToolType, + }) +} + +// skillsShellNode builds the skills graph node for a *harness-less* prompt +// agent: bundles are published as skill versions, referenced by name on the +// definition, and made runnable by a shell tool. +// +// This is the counterpart to toolboxNode, which serves managed agents. The two +// are mutually exclusive — a toolbox is only reachable from inside a harness +// sandbox, and a shell tool is rejected by a harness — so the caller picks one +// based on whether a harness is named. +func skillsShellNode( g *promptGraph, skills []skillBundle, ref *agent_yaml.ToolboxReference, - newBuilder func() (toolboxBuilder, error), + newAttacher func() (skillAttacher, error), ) *promptNode { if len(skills) == 0 && ref == nil { return nil } return &promptNode{ - Kind: nodeToolbox, + Kind: nodeSkill, + ID: promptSkillsDirName, + Validate: func() error { + // A toolbox is provisioned and reached through the harness sandbox. + // Without a harness there is nothing to reach it from, so accepting + // the reference would deploy an agent whose skills never run. + if ref != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "toolbox: is only available to an agent that names a harness", + "add 'harness: github-copilot' to agent.yaml, or remove 'toolbox:' and put the "+ + "skills in a skills/ folder next to agent.yaml", + ) + } + for _, s := range skills { + if strings.TrimSpace(s.Meta.Instructions) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("skill %q has no instructions (empty SKILL.md body)", s.Dir), + "add Markdown content below the frontmatter in the skill's SKILL.md", + ) + } + } + return nil + }, + Resolve: func(ctx context.Context) error { + attacher, err := newAttacher() + if err != nil { + return err + } + names, err := attacher.AttachSkills(ctx, skills) + if err != nil { + return err + } + for _, name := range names { + if !slices.Contains(g.managed.Skills, name) { + g.managed.Skills = append(g.managed.Skills, name) + } + } + injectShellTool(g.managed) + return nil + }, + } +} + +// skillsHarnessNode builds the skills graph node for a *harnessed* prompt +// agent: bundles are published as skill versions and pinned onto the harness, +// which provisions them into the sandbox that starts up to run the agent. +// +// This is the counterpart to skillsShellNode, which serves harness-less agents. +// Nothing is attached as a tool here — a skill is not a tool, and the harness +// loads its pinned skills when the environment starts. +func skillsHarnessNode( + g *promptGraph, + skills []skillBundle, + newPublisher func() (harnessSkillPublisher, error), +) *promptNode { + if len(skills) == 0 { + return nil + } + return &promptNode{ + Kind: nodeSkill, ID: promptSkillsDirName, Validate: func() error { - // SKILL.md parsing already validated name/description/body in - // scanSkillsDir. Version is optional (service-assigned), so nothing - // further to check per-skill here. for _, s := range skills { if strings.TrimSpace(s.Meta.Instructions) == "" { return exterrors.Validation( @@ -284,7 +403,57 @@ func toolboxNode( ) } } - if ref != nil && strings.TrimSpace(ref.Name) == "" { + return nil + }, + Resolve: func(ctx context.Context) error { + publisher, err := newPublisher() + if err != nil { + return err + } + published, err := publisher.PublishSkills(ctx, skills) + if err != nil { + return err + } + for _, s := range published { + if slices.ContainsFunc(g.managed.HarnessSkills, func(existing agent_yaml.HarnessSkillRef) bool { + return existing.Name == s.Name + }) { + continue + } + // Always pin the version that was just published, even when the + // author did not pin one in SKILL.md. The service returns a 500 + // for a skill reference with no version, so "follow the default" + // is not an option the wire format actually offers. + g.managed.HarnessSkills = append(g.managed.HarnessSkills, agent_yaml.HarnessSkillRef{ + Name: s.Name, + Version: s.Version, + }) + } + return nil + }, + } +} + +// toolboxNode attaches an existing shared toolbox by reference, as an mcp tool. +// +// It is reachable only from an explicit `toolbox:` block in agent.yaml. Skills +// no longer travel this path: every harnessed agent already gets a system +// toolbox whose name, version, endpoint and lifecycle the service owns, so azd +// creating a second toolbox of its own to carry skills both duplicated that and +// left the skills invisible as skills. +func toolboxNode( + g *promptGraph, + ref *agent_yaml.ToolboxReference, + newBuilder func() (toolboxBuilder, error), +) *promptNode { + if ref == nil { + return nil + } + return &promptNode{ + Kind: nodeToolbox, + ID: ref.Name, + Validate: func() error { + if strings.TrimSpace(ref.Name) == "" { return exterrors.Validation( exterrors.CodeInvalidAgentManifest, "toolbox reference is missing a name", @@ -298,33 +467,22 @@ func toolboxNode( if err != nil { return err } - - var ( - attachment toolboxAttachment - label string - ) - if ref != nil { - label = ref.Name - attachment, err = builder.ResolveToolbox(ctx, toolboxRef{Name: ref.Name, Version: ref.Version}) - } else { - label = g.managed.Name - attachment, err = builder.EnsureToolbox(ctx, g.managed.Name, skills) - } + attachment, err := builder.ResolveToolbox(ctx, toolboxRef{Name: ref.Name, Version: ref.Version}) if err != nil { return err } g.bindings[toolboxMcpURLBindingKey] = attachment.McpURL - injectMcpTool(g.managed, label, attachment.McpURL, attachment.ConnectionName) + injectMcpTool(g.managed, ref.Name, attachment.McpURL, attachment.ConnectionName) return nil }, } } -// foundryToolboxBuilder is the live toolboxBuilder backed by the Foundry skill -// and toolbox data-plane endpoints. +// foundryToolboxBuilder is the live toolboxBuilder backed by the Foundry +// toolbox data-plane endpoints. It holds no skills client: skills are published +// and pinned on the harness, never registered into a toolbox. type foundryToolboxBuilder struct { - skills *azure.FoundrySkillsClient toolboxes *azure.FoundryToolboxClient connections *azure.FoundryConnectionsARMClient resourceGroup string @@ -333,15 +491,27 @@ type foundryToolboxBuilder struct { projectEndpoint string } -// EnsureToolbox registers each skill bundle at its pinned version, creates a -// toolbox version referencing them, and returns the toolbox MCP url plus the -// project connection that fronts it. -func (b *foundryToolboxBuilder) EnsureToolbox( - ctx context.Context, toolboxName string, skills []skillBundle, -) (toolboxAttachment, error) { - // Skills are attached to a toolbox via a separate `skills` array of skill - // references (distinct from `tools`), per the Foundry Skills API. - skillRefs := make([]map[string]any, 0, len(skills)) +// publishedSkill is a skill bundle after it has been registered as a skill +// version on the project. +type publishedSkill struct { + Name string + Version string + // Pinned records that the author fixed a version in SKILL.md frontmatter, + // rather than following the skill's default_version. + Pinned bool +} + +// publishSkillBundles uploads and promotes each skill bundle, returning the +// registered name and version of each. +// +// This is shared by both prompt-agent flavors — a harnessed agent and a +// harness-less one differ in how skills are *reached*, not in how they are +// published. It takes the skills client directly so neither path has to hold a +// toolbox client it would not use. +func publishSkillBundles( + ctx context.Context, client *azure.FoundrySkillsClient, skills []skillBundle, +) ([]publishedSkill, error) { + published := make([]publishedSkill, 0, len(skills)) for _, s := range skills { // Upload every file in the bundle (SKILL.md plus any references/, // assets/, or other supporting files), not just SKILL.md. The service @@ -350,12 +520,12 @@ func (b *foundryToolboxBuilder) EnsureToolbox( // SKILL.md's body. files, err := readSkillBundleFiles(s.Path) if err != nil { - return toolboxAttachment{}, err + return nil, err } - version, err := b.skills.CreateSkillVersionFromFiles(ctx, s.Meta.Name, files) + version, err := client.CreateSkillVersionFromFiles(ctx, s.Meta.Name, files) if err != nil { - return toolboxAttachment{}, fmt.Errorf("registering skill %q: %w", s.Meta.Name, err) + return nil, fmt.Errorf("registering skill %q: %w", s.Meta.Name, err) } // Creating a version does NOT make it the skill's default_version — @@ -365,43 +535,59 @@ func (b *foundryToolboxBuilder) EnsureToolbox( // reference) never surfaces, making the update look like it didn't // happen. Promote every newly created version to default so the // latest deploy is always what's active. - if err := b.skills.PromoteSkillVersion(ctx, version.Name, version.Version); err != nil { - return toolboxAttachment{}, fmt.Errorf("promoting skill %q to version %s: %w", s.Meta.Name, version.Version, err) + if err := client.PromoteSkillVersion(ctx, version.Name, version.Version); err != nil { + return nil, fmt.Errorf("promoting skill %q to version %s: %w", s.Meta.Name, version.Version, err) } - ref := map[string]any{ - "type": "skill_reference", - "name": version.Name, - } - // Pin the reference to the created version only when the author pinned a - // version; otherwise follow the skill's default_version. - if strings.TrimSpace(s.Meta.Version) != "" { - ref["version"] = version.Version - } - skillRefs = append(skillRefs, ref) + published = append(published, publishedSkill{ + Name: version.Name, + Version: version.Version, + Pinned: strings.TrimSpace(s.Meta.Version) != "", + }) } + return published, nil +} - created, err := b.toolboxes.CreateToolboxVersion(ctx, toolboxName, &azure.CreateToolboxVersionRequest{ - Tools: []map[string]any{}, - Skills: skillRefs, - }) +// foundrySkillPublisher is the live publisher for both prompt-agent flavors. It +// holds only the skills client: neither path creates a toolbox, a toolbox +// version or a project connection. A harness-less agent runs its skills through +// a shell tool, and a harnessed agent has them provisioned into its sandbox. +type foundrySkillPublisher struct { + skills *azure.FoundrySkillsClient +} + +// AttachSkills publishes the bundles and returns their registered names. +func (p *foundrySkillPublisher) AttachSkills(ctx context.Context, skills []skillBundle) ([]string, error) { + published, err := publishSkillBundles(ctx, p.skills, skills) if err != nil { - return toolboxAttachment{}, fmt.Errorf("creating toolbox version: %w", err) + return nil, err } - - // Same reasoning as the skill promotion above: creating a toolbox version - // doesn't promote it, so the toolbox consumer endpoint (and the portal) - // would keep serving the previous version's tool/skill set otherwise. - if err := b.toolboxes.PromoteToolboxVersion(ctx, toolboxName, created.Version); err != nil { - return toolboxAttachment{}, fmt.Errorf("promoting toolbox %q to version %s: %w", toolboxName, created.Version, err) + names := make([]string, 0, len(published)) + for _, s := range published { + names = append(names, s.Name) } + return names, nil +} - mcpURL := b.mcpURL(created.Name, created.Version) - connName, err := b.ensureToolboxConnection(ctx, created.Name, mcpURL) - if err != nil { - return toolboxAttachment{}, err +// PublishSkills publishes the bundles and returns their name and version. +func (p *foundrySkillPublisher) PublishSkills( + ctx context.Context, skills []skillBundle, +) ([]publishedSkill, error) { + return publishSkillBundles(ctx, p.skills, skills) +} + +// newFoundrySkillPublisher constructs the live publisher from prompt settings. +func newFoundrySkillPublisher(settings *PromptAgentSettings) (*foundrySkillPublisher, error) { + if settings == nil || strings.TrimSpace(settings.ProjectEndpoint) == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "a Foundry project endpoint is required to register skills", + "run `azd up` to provision a Foundry project, or remove the skills/ folder", + ) } - return toolboxAttachment{McpURL: mcpURL, ConnectionName: connName}, nil + return &foundrySkillPublisher{ + skills: azure.NewFoundrySkillsClient(settings.ProjectEndpoint, promptCredential()), + }, nil } // ResolveToolbox confirms an existing toolbox and returns its MCP url plus the @@ -500,6 +686,15 @@ func readSkillBundleFiles(bundleDir string) (map[string][]byte, error) { if d.IsDir() { return nil } + // WalkDir does not follow symlinks, but os.ReadFile does. Reject links + // outright so a bundle cannot exfiltrate arbitrary local files. + if d.Type()&os.ModeSymlink != 0 { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("skill bundle file %q is a symbolic link", path), + "replace the link with the file itself; symlinks are not packaged", + ) + } content, readErr := os.ReadFile(path) //nolint:gosec // path derived from the agent's skills/ folder if readErr != nil { return readErr @@ -525,8 +720,8 @@ func newFoundryToolboxBuilder(settings *PromptAgentSettings) (toolboxBuilder, er if settings == nil || strings.TrimSpace(settings.ProjectEndpoint) == "" { return nil, exterrors.Validation( exterrors.CodeInvalidServiceConfig, - "a Foundry project endpoint is required to register skills / resolve a toolbox", - "run `azd up` to provision a Foundry project, or remove the skills/ folder", + "a Foundry project endpoint is required to resolve a toolbox", + "run `azd up` to provision a Foundry project, or remove the 'toolbox:' block from agent.yaml", ) } cred := promptCredential() @@ -547,7 +742,6 @@ func newFoundryToolboxBuilder(settings *PromptAgentSettings) (toolboxBuilder, er } } return &foundryToolboxBuilder{ - skills: azure.NewFoundrySkillsClient(settings.ProjectEndpoint, cred), toolboxes: azure.NewFoundryToolboxClient(settings.ProjectEndpoint, cred), connections: connections, resourceGroup: settings.ResourceGroup, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go index 5caa6acf01d..ea2dea379e5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go @@ -5,8 +5,10 @@ package project import ( "context" + "errors" "os" "path/filepath" + "slices" "strings" "testing" @@ -17,23 +19,10 @@ import ( type fakeToolboxBuilder struct { mcpURL string connName string - ensureCalls int resolveCalls int - lastSkills []skillBundle lastRef toolboxRef } -func (b *fakeToolboxBuilder) EnsureToolbox( - _ context.Context, _ string, skills []skillBundle, -) (toolboxAttachment, error) { - b.ensureCalls++ - b.lastSkills = skills - if b.mcpURL == "" { - b.mcpURL = "https://proj/toolboxes/agent/versions/1/mcp" - } - return toolboxAttachment{McpURL: b.mcpURL, ConnectionName: b.connName}, nil -} - func (b *fakeToolboxBuilder) ResolveToolbox(_ context.Context, ref toolboxRef) (toolboxAttachment, error) { b.resolveCalls++ b.lastRef = ref @@ -189,16 +178,32 @@ func TestInjectMcpTool_NotDuplicated(t *testing.T) { } } -func TestToolboxNode_PrimaryRegistersSkills(t *testing.T) { +// TestToolboxNode_SkillsDoNotCreateAToolbox pins the boundary the harness spec +// draws: every harnessed agent already has a service-owned system toolbox, so a +// skills/ folder must never cause azd to build one of its own. Only an explicit +// toolbox: reference reaches this node now. +func TestToolboxNode_SkillsDoNotCreateAToolbox(t *testing.T) { managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} managed.Name = "agent" g := &promptGraph{managed: managed, bindings: map[string]any{}} - fake := &fakeToolboxBuilder{} - skills := []skillBundle{{Dir: "s", Meta: skillMeta{ - Name: "s", Description: "d", Version: "1.0.0", Instructions: "do the thing", - }}} - node := toolboxNode(g, skills, nil, func() (toolboxBuilder, error) { return fake, nil }) + node := toolboxNode(g, nil, func() (toolboxBuilder, error) { + t.Fatal("builder must not be constructed without a toolbox reference") + return nil, nil + }) + if node != nil { + t.Fatal("expected nil node when no toolbox reference is declared") + } +} + +func TestToolboxNode_ReferenceExisting(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeToolboxBuilder{connName: "agent-toolbox"} + + ref := &agent_yaml.ToolboxReference{Name: "existing-tb", Version: "2"} + node := toolboxNode(g, ref, func() (toolboxBuilder, error) { return fake, nil }) if node == nil { t.Fatal("expected a toolbox node") } @@ -209,50 +214,298 @@ func TestToolboxNode_PrimaryRegistersSkills(t *testing.T) { t.Fatalf("resolve: %v", err) } - if fake.ensureCalls != 1 || fake.resolveCalls != 0 { - t.Errorf("expected 1 ensure, 0 resolve; got %d, %d", fake.ensureCalls, fake.resolveCalls) + if fake.resolveCalls != 1 { + t.Errorf("expected 1 resolve, got %d", fake.resolveCalls) + } + if fake.lastRef.Name != "existing-tb" || fake.lastRef.Version != "2" { + t.Errorf("ref: got %+v", fake.lastRef) } if g.bindings[toolboxMcpURLBindingKey] == nil { t.Error("expected toolbox_mcp_url binding") } - if len(managed.Tools) != 1 || managed.Tools[0].(map[string]any)["type"] != "mcp" { - t.Errorf("expected mcp tool, got %+v", managed.Tools) + if len(managed.Tools) != 1 { + t.Fatalf("expected mcp tool attached, got %+v", managed.Tools) + } + tool := managed.Tools[0].(map[string]any) + if tool["type"] != "mcp" || tool["project_connection_id"] != "agent-toolbox" { + t.Errorf("tool: got %+v", tool) } } -func TestToolboxNode_InjectsConnectionID(t *testing.T) { - managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} +func TestToolboxNode_NoneReturnsNil(t *testing.T) { + g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} + node := toolboxNode(g, nil, func() (toolboxBuilder, error) { return nil, nil }) + if node != nil { + t.Fatal("expected nil node when no reference") + } +} + +// fakeSkillAttacher records the bundles it was given and returns their names. +type fakeSkillAttacher struct { + attachCalls int + lastSkills []skillBundle + names []string + err error +} + +func (a *fakeSkillAttacher) AttachSkills(_ context.Context, skills []skillBundle) ([]string, error) { + a.attachCalls++ + a.lastSkills = skills + if a.err != nil { + return nil, a.err + } + if a.names != nil { + return a.names, nil + } + names := make([]string, 0, len(skills)) + for _, s := range skills { + names = append(names, s.Meta.Name) + } + return names, nil +} + +func TestSkillsShellNode_NoneReturnsNil(t *testing.T) { + g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} + node := skillsShellNode(g, nil, nil, func() (skillAttacher, error) { return nil, nil }) + if node != nil { + t.Fatal("expected nil node when no skills and no reference") + } +} + +// fakeHarnessSkillPublisher records the bundles it was given and echoes them +// back as published skills at a fixed version. +type fakeHarnessSkillPublisher struct { + calls int + lastSkills []skillBundle + published []publishedSkill + err error +} + +func (p *fakeHarnessSkillPublisher) PublishSkills( + _ context.Context, skills []skillBundle, +) ([]publishedSkill, error) { + p.calls++ + p.lastSkills = skills + if p.err != nil { + return nil, p.err + } + if p.published != nil { + return p.published, nil + } + out := make([]publishedSkill, 0, len(skills)) + for _, s := range skills { + out = append(out, publishedSkill{Name: s.Meta.Name, Version: "7"}) + } + return out, nil +} + +func TestSkillsHarnessNode_NoneReturnsNil(t *testing.T) { + g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} + node := skillsHarnessNode(g, nil, func() (harnessSkillPublisher, error) { return nil, nil }) + if node != nil { + t.Fatal("expected nil node when there are no skills") + } +} + +// TestSkillsHarnessNode_PinsVersionsAndAttachesNoTool is the core of the +// harnessed skills contract: skills land on the harness as versioned +// references, and nothing is added to tools. A skill is not a tool, and the +// toolbox that used to carry them is service-owned. +func TestSkillsHarnessNode_PinsVersionsAndAttachesNoTool(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: "github-copilot"} managed.Name = "agent" g := &promptGraph{managed: managed, bindings: map[string]any{}} - fake := &fakeToolboxBuilder{connName: "agent-toolbox"} + pub := &fakeHarnessSkillPublisher{} + + skills := []skillBundle{ + {Dir: "skill-a", Meta: skillMeta{Name: "skill-a", Description: "d", Instructions: "body"}}, + {Dir: "skill-b", Meta: skillMeta{Name: "skill-b", Description: "d", Instructions: "body"}}, + } + node := skillsHarnessNode(g, skills, func() (harnessSkillPublisher, error) { return pub, nil }) + if node == nil { + t.Fatal("expected a skills node") + } + if node.Kind != nodeSkill { + t.Errorf("kind: got %q, want %q", node.Kind, nodeSkill) + } + if err := node.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if pub.calls != 1 { + t.Errorf("expected 1 publish call, got %d", pub.calls) + } + want := []agent_yaml.HarnessSkillRef{ + {Name: "skill-a", Version: "7"}, + {Name: "skill-b", Version: "7"}, + } + if !slices.Equal(managed.HarnessSkills, want) { + t.Errorf("harness skills: got %+v, want %+v", managed.HarnessSkills, want) + } + if len(managed.Tools) != 0 { + t.Errorf("a skill must not become a tool, got %+v", managed.Tools) + } + if len(managed.Skills) != 0 { + t.Errorf("harnessed skills must not land on the definition-level field, got %+v", managed.Skills) + } +} - skills := []skillBundle{{Dir: "s", Meta: skillMeta{ - Name: "s", Description: "d", Instructions: "do the thing", +// TestSkillsHarnessNode_PinsVersionEvenWhenUnpinned guards the workaround for +// the service returning 500 for a reference with no version: azd always sends +// the version it just published, whether or not SKILL.md pinned one. +func TestSkillsHarnessNode_PinsVersionEvenWhenUnpinned(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: "github-copilot"} + g := &promptGraph{managed: managed, bindings: map[string]any{}} + pub := &fakeHarnessSkillPublisher{ + published: []publishedSkill{{Name: "skill-a", Version: "3", Pinned: false}}, + } + + skills := []skillBundle{{Dir: "skill-a", Meta: skillMeta{ + Name: "skill-a", Description: "d", Instructions: "body", }}} - node := toolboxNode(g, skills, nil, func() (toolboxBuilder, error) { return fake, nil }) + node := skillsHarnessNode(g, skills, func() (harnessSkillPublisher, error) { return pub, nil }) if err := node.Resolve(context.Background()); err != nil { t.Fatalf("resolve: %v", err) } - if len(managed.Tools) != 1 { - t.Fatalf("expected 1 tool, got %d", len(managed.Tools)) + if len(managed.HarnessSkills) != 1 || managed.HarnessSkills[0].Version != "3" { + t.Errorf("expected the published version pinned, got %+v", managed.HarnessSkills) } - tool := managed.Tools[0].(map[string]any) - if tool["project_connection_id"] != "agent-toolbox" { - t.Errorf("project_connection_id: got %v, want agent-toolbox", tool["project_connection_id"]) +} + +func TestSkillsHarnessNode_ResolveIsIdempotent(t *testing.T) { + managed := &agent_yaml.PromptAgent{ + Model: "m", + Instructions: "i", + Harness: "github-copilot", + HarnessSkills: []agent_yaml.HarnessSkillRef{{Name: "skill-a", Version: "7"}}, + } + g := &promptGraph{managed: managed, bindings: map[string]any{}} + pub := &fakeHarnessSkillPublisher{} + + skills := []skillBundle{{Dir: "skill-a", Meta: skillMeta{ + Name: "skill-a", Description: "d", Instructions: "body", + }}} + node := skillsHarnessNode(g, skills, func() (harnessSkillPublisher, error) { return pub, nil }) + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if len(managed.HarnessSkills) != 1 { + t.Errorf("expected no duplicate reference, got %+v", managed.HarnessSkills) + } +} + +func TestSkillsHarnessNode_RejectsEmptyInstructions(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: "github-copilot"} + g := &promptGraph{managed: managed, bindings: map[string]any{}} + pub := &fakeHarnessSkillPublisher{} + + skills := []skillBundle{{Dir: "empty", Meta: skillMeta{Name: "empty", Description: "d"}}} + node := skillsHarnessNode(g, skills, func() (harnessSkillPublisher, error) { return pub, nil }) + + err := node.Validate() + if err == nil { + t.Fatal("expected a skill with no instructions to be rejected") + } + if !strings.Contains(err.Error(), "empty") { + t.Errorf("error should name the skill, got: %v", err) + } + if pub.calls != 0 { + t.Errorf("validation failure must not publish skills, got %d calls", pub.calls) + } +} + +func TestSkillsHarnessNode_PublisherErrorPropagates(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: "github-copilot"} + g := &promptGraph{managed: managed, bindings: map[string]any{}} + pub := &fakeHarnessSkillPublisher{err: errors.New("boom")} + + skills := []skillBundle{{Dir: "skill-a", Meta: skillMeta{ + Name: "skill-a", Description: "d", Instructions: "body", + }}} + node := skillsHarnessNode(g, skills, func() (harnessSkillPublisher, error) { return pub, nil }) + + if err := node.Resolve(context.Background()); err == nil { + t.Fatal("expected the publish error to propagate") + } + if len(managed.HarnessSkills) != 0 { + t.Errorf("failed publish must leave the definition untouched, got %+v", managed.HarnessSkills) } } -func TestToolboxNode_FallbackReferenceExisting(t *testing.T) { +// TestSkillsShellNode_RejectsToolboxReference pins the mutual exclusion between +// this node and toolboxNode: a toolbox is only reachable from inside a harness +// sandbox, so accepting toolbox: here would publish an agent whose skills can +// never run. +func TestSkillsShellNode_RejectsToolboxReference(t *testing.T) { managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} managed.Name = "agent" g := &promptGraph{managed: managed, bindings: map[string]any{}} - fake := &fakeToolboxBuilder{} + fake := &fakeSkillAttacher{} ref := &agent_yaml.ToolboxReference{Name: "existing-tb", Version: "2"} - node := toolboxNode(g, nil, ref, func() (toolboxBuilder, error) { return fake, nil }) + node := skillsShellNode(g, nil, ref, func() (skillAttacher, error) { return fake, nil }) if node == nil { - t.Fatal("expected a toolbox node") + t.Fatal("expected a skills node") + } + + err := node.Validate() + if err == nil { + t.Fatal("expected toolbox: to be rejected without a harness") + } + if !strings.Contains(err.Error(), "harness") { + t.Errorf("error should point at the harness requirement, got: %v", err) + } + if fake.attachCalls != 0 { + t.Errorf("validation failure must not publish skills, got %d calls", fake.attachCalls) + } +} + +// TestSkillsShellNode_RejectsEmptyInstructions covers a SKILL.md whose body is +// blank: the bundle would publish, but the agent would have no instructions +// telling it what the skill does. +func TestSkillsShellNode_RejectsEmptyInstructions(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + + skills := []skillBundle{{Dir: "empty", Meta: skillMeta{Name: "empty", Description: "d"}}} + node := skillsShellNode(g, skills, nil, func() (skillAttacher, error) { + return &fakeSkillAttacher{}, nil + }) + + err := node.Validate() + if err == nil { + t.Fatal("expected a skill with no instructions to be rejected") + } + if !strings.Contains(err.Error(), "empty") { + t.Errorf("error should name the offending skill, got: %v", err) + } +} + +// TestSkillsShellNode_PublishesAndInjectsShell asserts the node's whole job: +// publish the bundles, reference the returned names on the definition, and add +// the shell tool that makes them runnable. +func TestSkillsShellNode_PublishesAndInjectsShell(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeSkillAttacher{} + + skills := []skillBundle{ + {Dir: "a", Meta: skillMeta{Name: "skill-a", Description: "d", Instructions: "do a"}}, + {Dir: "b", Meta: skillMeta{Name: "skill-b", Description: "d", Instructions: "do b"}}, + } + node := skillsShellNode(g, skills, nil, func() (skillAttacher, error) { return fake, nil }) + if node == nil { + t.Fatal("expected a skills node") + } + if node.Kind != nodeSkill { + t.Errorf("kind: got %v, want %v", node.Kind, nodeSkill) } if err := node.Validate(); err != nil { t.Fatalf("validate: %v", err) @@ -261,21 +514,73 @@ func TestToolboxNode_FallbackReferenceExisting(t *testing.T) { t.Fatalf("resolve: %v", err) } - if fake.resolveCalls != 1 || fake.ensureCalls != 0 { - t.Errorf("expected 1 resolve, 0 ensure; got %d, %d", fake.resolveCalls, fake.ensureCalls) + if fake.attachCalls != 1 { + t.Errorf("expected 1 attach call, got %d", fake.attachCalls) } - if fake.lastRef.Name != "existing-tb" || fake.lastRef.Version != "2" { - t.Errorf("ref: got %+v", fake.lastRef) + if len(fake.lastSkills) != 2 { + t.Errorf("expected both bundles published, got %d", len(fake.lastSkills)) + } + if want := []string{"skill-a", "skill-b"}; !slices.Equal(managed.Skills, want) { + t.Errorf("skills: got %v, want %v", managed.Skills, want) } if len(managed.Tools) != 1 { - t.Errorf("expected mcp tool attached, got %+v", managed.Tools) + t.Fatalf("expected the shell tool to be injected, got %+v", managed.Tools) + } + if got := managed.Tools[0].(map[string]any)["type"]; got != promptSkillShellToolType { + t.Errorf("tool type: got %v, want %v", got, promptSkillShellToolType) } } -func TestToolboxNode_NoneReturnsNil(t *testing.T) { - g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} - node := toolboxNode(g, nil, nil, func() (toolboxBuilder, error) { return nil, nil }) - if node != nil { - t.Fatal("expected nil node when no skills and no reference") +// TestSkillsShellNode_ResolveIsIdempotent covers a re-run of the deploy graph: +// neither the skill names nor the shell tool may be duplicated, since both are +// sent verbatim to the API. +func TestSkillsShellNode_ResolveIsIdempotent(t *testing.T) { + managed := &agent_yaml.PromptAgent{ + Model: "m", + Instructions: "i", + Skills: []string{"skill-a"}, + Tools: []any{map[string]any{"type": promptSkillShellToolType}}, + } + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeSkillAttacher{} + + skills := []skillBundle{ + {Dir: "a", Meta: skillMeta{Name: "skill-a", Description: "d", Instructions: "do a"}}, + } + node := skillsShellNode(g, skills, nil, func() (skillAttacher, error) { return fake, nil }) + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if want := []string{"skill-a"}; !slices.Equal(managed.Skills, want) { + t.Errorf("skills: got %v, want %v", managed.Skills, want) + } + if len(managed.Tools) != 1 { + t.Errorf("expected the existing shell tool to be reused, got %+v", managed.Tools) + } +} + +// TestSkillsShellNode_AttacherErrorPropagates asserts a publish failure fails +// the deploy rather than leaving the definition half-wired -- an agent that +// references skills the service never received. +func TestSkillsShellNode_AttacherErrorPropagates(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeSkillAttacher{err: errors.New("publish failed")} + + skills := []skillBundle{ + {Dir: "a", Meta: skillMeta{Name: "skill-a", Description: "d", Instructions: "do a"}}, + } + node := skillsShellNode(g, skills, nil, func() (skillAttacher, error) { return fake, nil }) + + err := node.Resolve(context.Background()) + if err == nil { + t.Fatal("expected the attacher error to propagate") + } + if len(managed.Skills) != 0 || len(managed.Tools) != 0 { + t.Errorf("definition must be left untouched on failure: skills=%v tools=%v", + managed.Skills, managed.Tools) } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index b195b98113c..f4e8bdd926e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -241,7 +241,6 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er // their entire deploy target in the service config, so skip the // subscription/tenant/credential resolution the hosted path needs. if serviceIsPromptAgent(p.serviceConfig) { - fmt.Fprintf(os.Stderr, "Project path: %s, Service path: %s\n", proj.Project.Path, fullPath) return p.resolveAgentDefinitionPath(proj.Project.Path, servicePath, fullPath) } @@ -331,6 +330,32 @@ func (p *AgentServiceTargetProvider) resolveAgentDefinitionPath( return nil } + // Explicit reference: `manifest:` on the service entry names the agent + // definition file. It wins over both the inline shape and the agent.yaml + // convention, so a developer who wants to name the file something else -- + // or keep several manifests side by side in one folder -- can say so in + // azure.yaml instead of relying on a filename azd hardcodes. + if declared := declaredAgentManifest(p.serviceConfig); declared != "" { + resolved, err := resolveDeclaredManifestPath(projectPath, servicePath, declared, p.serviceConfig.Name) + if err != nil { + return err + } + if _, statErr := os.Stat(resolved); statErr != nil { + // A declared-but-missing manifest is a typo, not an opt-out. + // Falling back to the convention here would deploy a different + // manifest than the one azure.yaml names. + return exterrors.Dependency( + exterrors.CodeAgentDefinitionNotFound, + fmt.Sprintf("agent manifest %q declared by service %q does not exist", declared, p.serviceConfig.Name), + "correct the manifest: path in azure.yaml, or remove it to use the default agent.yaml", + ) + } + p.agentDefinitionPath = resolved + fmt.Printf("Using agent definition: %s\n", color.New(color.FgHiGreen).Sprint(resolved)) + p.deployContextReady = true + return nil + } + // Unified shape: the agent definition is carried inline on the service entry, // so no on-disk agent.yaml is required. if _, _, found, _, defErr := AgentDefinitionFromResolvedService( @@ -378,10 +403,93 @@ func (p *AgentServiceTargetProvider) resolveAgentDefinitionPath( return exterrors.Dependency( exterrors.CodeAgentDefinitionNotFound, fmt.Sprintf("agent definition file not found: no agent.yaml or agent.yml found in %s", fullPath), - "add an agent.yaml/agent.yml file to the service directory or set AGENT_DEFINITION_PATH", + "add an agent.yaml/agent.yml file to the service directory, "+ + "declare manifest: on the service in azure.yaml, or set AGENT_DEFINITION_PATH", ) } +// AgentManifestServiceKey is the azure.yaml service key that points at the +// agent manifest file, relative to the service's project directory. +const AgentManifestServiceKey = "manifest" + +// declaredAgentManifest returns the manifest path declared on the service entry +// in azure.yaml, or "" when the service relies on the agent.yaml convention. +// +// Service-level properties are checked before the nested config block so the +// unified shape wins, matching how the inline agent definition is resolved. +func declaredAgentManifest(svc *azdext.ServiceConfig) string { + if svc == nil { + return "" + } + for _, props := range []*structpb.Struct{svc.GetAdditionalProperties(), svc.GetConfig()} { + if props == nil { + continue + } + value, ok := props.GetFields()[AgentManifestServiceKey] + if !ok { + continue + } + if declared := strings.TrimSpace(value.GetStringValue()); declared != "" { + return declared + } + } + return "" +} + +// resolveDeclaredManifestPath resolves a `manifest:` value against the service +// directory and confines it there. +// +// Confinement is to the *service* directory rather than the project root: a +// manifest is part of one service's source, and letting it reach across into a +// sibling service's folder makes the two services silently share state that +// neither declares. +func resolveDeclaredManifestPath(projectPath, servicePath, declared, serviceName string) (string, error) { + if filepath.IsAbs(declared) || strings.HasPrefix(declared, "/") || strings.HasPrefix(declared, `\`) { + return "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("manifest %q on service %q must be a relative path", declared, serviceName), + "use a path relative to the service's project directory (e.g. manifest: agents/triage.yaml)", + ) + } + + serviceDir, err := paths.JoinAllowRoot(projectPath, servicePath) + if err != nil { + return "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("invalid project path for service %q: %s", serviceName, err), + "update azure.yaml so the service's project directory stays within the project", + ) + } + + resolved, err := paths.JoinAllowRoot(projectPath, servicePath, filepath.FromSlash(declared)) + if err != nil { + return "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("invalid manifest path %q on service %q: %s", declared, serviceName, err), + "update azure.yaml so the manifest stays within the service's project directory", + ) + } + + rel, err := filepath.Rel(serviceDir, resolved) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("manifest %q on service %q resolves outside the service directory", declared, serviceName), + "point manifest: at a file inside the service's project directory", + ) + } + + if ext := strings.ToLower(filepath.Ext(resolved)); ext != ".yaml" && ext != ".yml" { + return "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("manifest %q on service %q must be a YAML file (.yaml or .yml)", declared, serviceName), + "point manifest: at a .yaml or .yml file", + ) + } + + return resolved, nil +} + // ensureEnv lazily populates p.env from the azd host. Idempotent and cheap // enough for non-deploy entrypoints (Endpoints, registerAgentEnvironmentVariables). func (p *AgentServiceTargetProvider) ensureEnv(ctx context.Context) error { @@ -414,9 +522,10 @@ func (p *AgentServiceTargetProvider) Endpoints( targetResource *azdext.TargetResource, ) ([]string, error) { // Prompt agents expose a single workspace-rooted Responses endpoint on the - // harness. Build it from the service config rather than azd env vars. + // harness. Build it from the service config, resolved against the azd + // environment so `azd show` reports the same target deploy published. if p.isPromptAgentService() { - settings, err := p.promptAgentSettings() + settings, err := p.resolvedPromptAgentSettings(ctx) if err != nil { return nil, err } @@ -494,9 +603,11 @@ func (p *AgentServiceTargetProvider) GetTargetResource( // Prompt agents target the managed harness, not an ARM Foundry project. // Synthesize a target resource from the harness workspace tuple so core // azd has something to display without resolving a CognitiveServices - // project that does not exist for this flow. + // project that does not exist for this flow. Resolve against the azd + // environment first so non-guided projects do not display the placeholder + // tuple stored in azure.yaml. if p.isPromptAgentService() { - settings, err := p.promptAgentSettings() + settings, err := p.resolvedPromptAgentSettings(ctx) if err != nil { return nil, err } @@ -1088,9 +1199,14 @@ func (p *AgentServiceTargetProvider) Deploy( ) (*azdext.ServiceDeployResult, error) { // Prompt agents are created on the managed harness, not the Foundry // service. Dispatch to the dedicated harness deploy path before any - // ARM/Foundry resolution the hosted path requires. + // ARM/Foundry resolution the hosted path requires. The deploy context still + // has to be resolved first: deployPromptAgent loads agent.yaml through + // p.agentDefinitionPath, which is empty until ensureDeployContext runs. if p.isPromptAgentService() { - return p.deployPromptAgent(ctx, serviceConfig, progress) + if err := p.ensureDeployContext(ctx); err != nil { + return nil, err + } + return p.deployPromptAgent(ctx, p.serviceConfig, progress) } if err := p.ensureDeployContext(ctx); err != nil { @@ -1291,61 +1407,26 @@ func validateMemoryStores(stores []MemoryStore) error { return nil } -// memoryStoreDefinitionDrift returns a human-readable list of the fields where the declared -// definition diverges from the live store. Only fields the user explicitly declared are -// compared, so unset options (which fall back to service defaults) never report false drift. -func memoryStoreDefinitionDrift(declared, live azure.MemoryStoreDefinition) []string { - var drift []string - - if declared.ChatModel != live.ChatModel { - drift = append(drift, fmt.Sprintf("chatModel (declared %q, current %q)", - declared.ChatModel, live.ChatModel)) - } - if declared.EmbeddingModel != live.EmbeddingModel { - drift = append(drift, fmt.Sprintf("embeddingModel (declared %q, current %q)", - declared.EmbeddingModel, live.EmbeddingModel)) - } - - if declared.Options == nil { - return drift - } - - var liveOpts azure.MemoryStoreOptions - if live.Options != nil { - liveOpts = *live.Options - } - - if boolPtrDiffers(declared.Options.ChatSummaryEnabled, liveOpts.ChatSummaryEnabled) { - drift = append(drift, fmt.Sprintf("options.chatSummaryEnabled (declared %v)", - *declared.Options.ChatSummaryEnabled)) - } - if boolPtrDiffers(declared.Options.UserProfileEnabled, liveOpts.UserProfileEnabled) { - drift = append(drift, fmt.Sprintf("options.userProfileEnabled (declared %v)", - *declared.Options.UserProfileEnabled)) - } - if boolPtrDiffers(declared.Options.ProceduralMemoryEnabled, liveOpts.ProceduralMemoryEnabled) { - drift = append(drift, fmt.Sprintf("options.proceduralMemoryEnabled (declared %v)", - *declared.Options.ProceduralMemoryEnabled)) - } - if declared.Options.DefaultTTLSeconds != nil && - (liveOpts.DefaultTTLSeconds == nil || *declared.Options.DefaultTTLSeconds != *liveOpts.DefaultTTLSeconds) { - drift = append(drift, fmt.Sprintf("options.defaultTtlSeconds (declared %d)", - *declared.Options.DefaultTTLSeconds)) - } - if declared.Options.UserProfileDetails != "" && - declared.Options.UserProfileDetails != liveOpts.UserProfileDetails { - drift = append(drift, "options.userProfileDetails") - } - - return drift +// azureYamlMemoryStoreLabels maps the wire field paths reported by +// diffMemoryStoreDefinition to the camelCase keys used under an agent service's +// memoryStores: list, so a drift warning names the key as authored. +var azureYamlMemoryStoreLabels = map[string]string{ + "chat_model": "chatModel", + "embedding_model": "embeddingModel", + "options.chat_summary_enabled": "options.chatSummaryEnabled", + "options.user_profile_enabled": "options.userProfileEnabled", + "options.procedural_memory_enabled": "options.proceduralMemoryEnabled", + "options.default_ttl_seconds": "options.defaultTtlSeconds", + "options.user_profile_details": "options.userProfileDetails", } -// boolPtrDiffers reports whether a declared bool pointer is set and differs from the live value. -func boolPtrDiffers(declared, live *bool) bool { - if declared == nil { - return false - } - return live == nil || *declared != *live +// memoryStoreDefinitionDrift returns a human-readable list of the fields where the declared +// definition diverges from the live store, named with the azure.yaml keys. +func memoryStoreDefinitionDrift(declared, live azure.MemoryStoreDefinition) []string { + return describeMemoryStoreDrift( + diffMemoryStoreDefinition(declared, live), + azureYamlMemoryStoreLabels, + ) } // writeMemoryStoreDriftWarning warns that azure.yaml changes were not applied to an existing store. @@ -1360,29 +1441,19 @@ func writeMemoryStoreDriftWarning(name string, drift []string) { // mapMemoryStoreOptions converts the azure.yaml memory store options into the API request shape. // It returns nil when no options are configured (or all fields are unset) so the service applies -// its own defaults, rather than sending an empty options object that the service might treat -// differently from an omitted one. +// its own defaults. func mapMemoryStoreOptions(options *MemoryStoreOptions) *azure.MemoryStoreOptions { - if options == nil || memoryStoreOptionsEmpty(options) { + if options == nil { return nil } - return &azure.MemoryStoreOptions{ + return memoryStoreOptionsOrNil(&azure.MemoryStoreOptions{ ChatSummaryEnabled: options.ChatSummaryEnabled, UserProfileEnabled: options.UserProfileEnabled, ProceduralMemoryEnabled: options.ProceduralMemoryEnabled, DefaultTTLSeconds: options.DefaultTtlSeconds, UserProfileDetails: options.UserProfileDetails, - } -} - -// memoryStoreOptionsEmpty reports whether every memory store option field is unset. -func memoryStoreOptionsEmpty(options *MemoryStoreOptions) bool { - return options.ChatSummaryEnabled == nil && - options.UserProfileEnabled == nil && - options.ProceduralMemoryEnabled == nil && - options.DefaultTtlSeconds == nil && - options.UserProfileDetails == "" + }) } // shouldUsePreBuiltImage determines whether to use a pre-built image. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go index e4a9413745c..1012cb62528 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go @@ -7,10 +7,10 @@ import ( "context" "errors" "fmt" + "log" "net/http" "net/url" "os" - "path/filepath" "runtime/debug" "slices" "strings" @@ -50,7 +50,16 @@ func (p *AgentServiceTargetProvider) isPromptAgentService() bool { // promptAgentSettings extracts and validates the prompt-agent harness settings // from the service config, applying environment-variable overrides. -func (p *AgentServiceTargetProvider) promptAgentSettings() (*PromptAgentSettings, error) { +// +// `azd ai agent init` writes every promptAgent field as a ${VAR} reference so +// azure.yaml stays portable, so the block is expanded against env (the azd +// environment, falling back to the process environment) before it is layered +// over the defaults. A reference whose variable is unset expands to "" and +// therefore leaves the corresponding default in place, which is what lets a +// project be cloned into an environment that has not been provisioned yet. +// Projects that carry literal values keep working -- expansion leaves a string +// with no ${...} in it untouched. +func (p *AgentServiceTargetProvider) promptAgentSettings(env map[string]string) (*PromptAgentSettings, error) { var cfg ServiceTargetAgentConfig if err := UnmarshalStruct(p.serviceConfig.Config, &cfg); err != nil { return nil, exterrors.Validation( @@ -59,25 +68,84 @@ func (p *AgentServiceTargetProvider) promptAgentSettings() (*PromptAgentSettings "check the service configuration in azure.yaml", ) } - if cfg.PromptAgent == nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - "service config is missing the promptAgent block", - "re-run `azd ai agent init` to scaffold the prompt agent service", - ) + configured, err := expandPromptAgentSettings(cfg.PromptAgent, env) + if err != nil { + return nil, err + } + settings := DefaultPromptAgentSettings() + settings.overlay(configured) + settings.ApplyEnvOverrides() + if err := settings.Validate(); err != nil { + return nil, err + } + return &settings, nil +} + +// expandPromptAgentSettings returns a copy of src with ${VAR} references in +// every field resolved against env, falling back to the process environment for +// variables the azd environment does not define. A nil src returns nil. +func expandPromptAgentSettings( + src *PromptAgentSettings, + env map[string]string, +) (*PromptAgentSettings, error) { + if src == nil { + return nil, nil + } + lookup := func(name string) string { + if v, ok := env[name]; ok { + return v + } + v, _ := os.LookupEnv(name) + return v + } + expanded := *src + for name, field := range map[string]*string{ + "baseUrl": &expanded.BaseURL, + "subscriptionId": &expanded.SubscriptionID, + "resourceGroup": &expanded.ResourceGroup, + "workspace": &expanded.Workspace, + "projectEndpoint": &expanded.ProjectEndpoint, + "apiVersion": &expanded.APIVersion, + "modelEndpoint": &expanded.ModelEndpoint, + } { + value, err := ExpandEnv(strings.TrimSpace(*field), lookup) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("failed to expand promptAgent.%s: %s", name, err), + "check the ${VAR} references in the promptAgent block in azure.yaml", + ) + } + *field = strings.TrimSpace(value) + } + return &expanded, nil +} + +// resolvedPromptAgentSettings returns the prompt-agent settings with the same +// azd environment-derived target resolution deployPromptAgent applies. Read-only +// callers (Endpoints, GetTargetResource) must use this rather than +// promptAgentSettings: a non-guided init stores a placeholder +// subscription/resource-group/workspace tuple in azure.yaml and only the azd +// environment knows the real Foundry target, so the raw settings would report +// `test-rg`/`test-ws` even after a successful deploy. +func (p *AgentServiceTargetProvider) resolvedPromptAgentSettings( + ctx context.Context, +) (*PromptAgentSettings, error) { + env, err := p.azdEnvValues(ctx) + if err != nil { + return nil, fmt.Errorf("reading the azd environment: %w", err) + } + settings, err := p.promptAgentSettings(env) + if err != nil { + return nil, err } - cfg.PromptAgent.ApplyEnvOverrides() - if err := cfg.PromptAgent.Validate(); err != nil { + if _, err := ResolvePromptTargetFromEnv(settings, env); err != nil { return nil, err } - return cfg.PromptAgent, nil + return settings, nil } // loadPromptAgentDefinition reads the agent.yaml as a bare PromptAgent. -// -// Convention: when the YAML omits inline `instructions:`, a sibling -// `instructions.md` (next to agent.yaml) is used as the agent's instructions. -// Inline `instructions:` always takes precedence over the file. func (p *AgentServiceTargetProvider) loadPromptAgentDefinition() (agent_yaml.PromptAgent, error) { data, err := os.ReadFile(p.agentDefinitionPath) if err != nil { @@ -106,22 +174,9 @@ func (p *AgentServiceTargetProvider) loadPromptAgentDefinition() (agent_yaml.Pro ) } - // Convention: fall back to a sibling instructions.md when instructions are - // not declared inline. Inline instructions win. - if strings.TrimSpace(promptDef.Instructions) == "" { - instructionsPath := filepath.Join(filepath.Dir(p.agentDefinitionPath), promptInstructionsFileName) - if content, readErr := os.ReadFile(instructionsPath); readErr == nil { - promptDef.Instructions = string(content) - } - } - return promptDef, nil } -// promptInstructionsFileName is the conventional sidecar file whose contents -// become the prompt agent's instructions when none are declared inline. -const promptInstructionsFileName = "instructions.md" - // containerOnlyPromptFields lists agent.yaml keys that are only meaningful for // hosted (container) agents and are therefore rejected for kind: prompt. var containerOnlyPromptFields = []string{ @@ -170,11 +225,16 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( ctx context.Context, serviceConfig *azdext.ServiceConfig, progress azdext.ProgressReporter, -) (*azdext.ServiceDeployResult, error) { +) (result *azdext.ServiceDeployResult, err error) { + // Convert an unexpected panic into a deploy error. Deploy handlers are + // expected to return errors to azd; re-panicking would tear down the whole + // extension process and azd would surface a transport failure instead of an + // actionable deploy error. defer func() { if r := recover(); r != nil { - fmt.Fprintf(os.Stderr, "panic in deployPromptAgent: %v\n%s\n", r, debug.Stack()) - panic(r) + log.Printf("panic in deployPromptAgent: %v\n%s", r, debug.Stack()) + result = nil + err = fmt.Errorf("unexpected error deploying prompt agent: %v", r) } }() @@ -183,7 +243,20 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( return nil, err } - settings, err := p.promptAgentSettings() + // The azd environment is read before the settings because azure.yaml states + // the promptAgent block as ${VAR} references that resolve against it. + // + // A failed env read is fatal: skipping it would also skip + // ResolvePromptTargetFromEnv and its AZURE_AI_PROJECT_ID validation, leaving + // the placeholder tuple in place so the create call goes out against a + // workspace that never existed and the user sees WorkspaceNotFound instead of + // the real cause. + env, err := p.azdEnvValues(ctx) + if err != nil { + return nil, fmt.Errorf("reading the azd environment: %w", err) + } + + settings, err := p.promptAgentSettings(env) if err != nil { return nil, err } @@ -194,58 +267,55 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( // project, and the deploy targets it. The overlay is a no-op unless the azd // environment actually holds a resolved project (AZURE_AI_PROJECT_NAME), // so the local-dev fake tuple is preserved when no project was provisioned. + projectScopedTarget := false - if env, envErr := p.azdEnvValues(ctx); envErr == nil { - mappedFromProjectID, mapErr := ResolvePromptTargetFromEnv(settings, env) - if mapErr != nil { - return nil, mapErr - } - projectScopedTarget = mappedFromProjectID - if projectScopedTarget { - fmt.Fprintf( - os.Stderr, - "Resolved managed prompt target from AZURE_AI_PROJECT_ID: subscription=%q resourceGroup=%q workspace=%q.\n", - settings.SubscriptionID, - settings.ResourceGroup, - settings.Workspace, - ) - } + mappedFromProjectID, mapErr := ResolvePromptTargetFromEnv(settings, env) + if mapErr != nil { + return nil, mapErr + } + projectScopedTarget = mappedFromProjectID + if projectScopedTarget { + fmt.Fprintf( + os.Stderr, + "Resolved managed prompt target from AZURE_AI_PROJECT_ID: subscription=%q resourceGroup=%q workspace=%q.\n", + settings.SubscriptionID, + settings.ResourceGroup, + settings.Workspace, + ) + } - // When the service already has an explicit non-placeholder workspace, - // trust it and avoid the RG-wide discovery path entirely. - workspaceKnown := strings.TrimSpace(settings.Workspace) != "" && - settings.Workspace != DefaultPromptWorkspace + // When the service already has an explicit non-placeholder workspace, + // trust it and avoid the RG-wide discovery path entirely. + workspaceKnown := strings.TrimSpace(settings.Workspace) != "" && + settings.Workspace != DefaultPromptWorkspace - if !workspaceKnown && !projectScopedTarget { - if ws, ok := p.resolvePromptWorkspaceFromAzure(ctx, settings, env); ok { - if !strings.EqualFold(ws, settings.Workspace) { - fmt.Fprintf(os.Stderr, "Resolved prompt workspace to %q (was %q).\n", ws, settings.Workspace) - settings.Workspace = ws - } - } else { - // No AML workspace found — provision one. The managed harness API - // requires Microsoft.MachineLearningServices/workspaces/{name} to exist. - if progress != nil { - progress(fmt.Sprintf("Workspace %q not found; provisioning an AML workspace now", settings.Workspace)) - } - if createErr := ensurePromptWorkspaceExists(ctx, settings, env, progress); createErr != nil { - fmt.Fprintf(os.Stderr, "Warning: AML workspace provisioning failed: %v\n", createErr) - } + if !workspaceKnown && !projectScopedTarget { + if ws, ok := p.resolvePromptWorkspaceFromAzure(ctx, settings, env); ok { + if !strings.EqualFold(ws, settings.Workspace) { + fmt.Fprintf(os.Stderr, "Resolved prompt workspace to %q (was %q).\n", ws, settings.Workspace) + settings.Workspace = ws } - } else if workspaceKnown && !projectScopedTarget { + } else { // No AML workspace found — provision one. The managed harness API - // Keep the explicit workspace from azure.yaml / env and skip discovery. - fmt.Fprintf(os.Stderr, "Using configured prompt workspace %q.\n", settings.Workspace) + // requires Microsoft.MachineLearningServices/workspaces/{name} to exist. + if progress != nil { + progress(fmt.Sprintf("Workspace %q not found; provisioning an AML workspace now", settings.Workspace)) + } + if createErr := ensurePromptWorkspaceExists(ctx, settings, env, progress); createErr != nil { + fmt.Fprintf(os.Stderr, "Warning: AML workspace provisioning failed: %v\n", createErr) + } } + } else if workspaceKnown && !projectScopedTarget { + // Keep the explicit workspace from azure.yaml / env and skip discovery. + fmt.Fprintf(os.Stderr, "Using configured prompt workspace %q.\n", settings.Workspace) } // Resolve the prompt agent's dependency graph. This validates the whole // graph (model + instructions, and — as later stages land — folders, // connections, and skills) and resolves convention-based dependencies, - // enriching the definition before the create request is built. Env values - // are best-effort; a nil map simply means nothing to overlay. - graphEnv, _ := p.azdEnvValues(ctx) - if err := p.resolvePromptAgentGraph(ctx, &managed, settings, graphEnv, progress); err != nil { + // enriching the definition before the create request is built. + bindings, err := p.resolvePromptAgentGraph(ctx, &managed, settings, env, progress) + if err != nil { return nil, err } @@ -296,7 +366,7 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( fmt.Fprintf(os.Stderr, "Prompt agent %q version %s is already active.\n", request.Name, latest.Version) } - if err := p.registerPromptAgentEnvVars(ctx, serviceConfig, request.Name, latest.Version, settings); err != nil { + if err := p.registerPromptAgentEnvVars(ctx, serviceConfig, request.Name, latest.Version, settings, bindings); err != nil { return nil, err } @@ -311,6 +381,20 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( // (https://.services.ai.azure.com/api/projects//agents?api-version=v1). const ProjectEndpointAPIVersion = "v1" +// promptProjectEndpointEnvKeys lists the azd environment keys that may carry +// the Foundry project data-plane endpoint, in precedence order. +// +// FOUNDRY_PROJECT_ENDPOINT is what the microsoft.foundry provisioning provider +// and `azd ai agent init` write today; AZURE_AI_PROJECT_ENDPOINT is the older +// name still emitted by hand-authored infra/ templates. Both must be honored: +// reading only the latter leaves ProjectEndpoint empty after a greenfield +// provision, and the deploy then falls back to the legacy workspace-rooted +// harness route, which 404s because a Foundry project is not an AML workspace. +var promptProjectEndpointEnvKeys = []string{ + "AZURE_AI_PROJECT_ENDPOINT", + "FOUNDRY_PROJECT_ENDPOINT", +} + // ResolvePromptTargetFromEnv applies azd environment-derived overrides to the // prompt settings so both deploy and the lifecycle commands (show/invoke/list/ // delete) target the same managed agent route. @@ -318,10 +402,10 @@ const ProjectEndpointAPIVersion = "v1" // It resolves the Foundry project data-plane endpoint // (https://.services.ai.azure.com/api/projects/), preferring // the value already on the settings (set via interactive init) and otherwise -// falling back to AZURE_AI_PROJECT_ENDPOINT in the azd environment (covers -// --no-prompt and the provisioned-project path). When a project endpoint is -// available it becomes the authoritative routing target, the api-version is -// normalized to v1, and the model endpoint is derived from the account host. +// falling back to the azd environment (covers --no-prompt and the provisioned- +// project path). When a project endpoint is available it becomes the +// authoritative routing target, the api-version is normalized to v1, and the +// model endpoint is derived from the account host. // // It returns true when a project-scoped target was resolved. func ResolvePromptTargetFromEnv(settings *PromptAgentSettings, env map[string]string) (bool, error) { @@ -337,8 +421,11 @@ func ResolvePromptTargetFromEnv(settings *PromptAgentSettings, env map[string]st // Prefer the config-supplied project endpoint (interactive init); otherwise // read it from the azd environment (--no-prompt / provisioned project). if strings.TrimSpace(settings.ProjectEndpoint) == "" { - if pe := strings.TrimSpace(env["AZURE_AI_PROJECT_ENDPOINT"]); pe != "" { - settings.ProjectEndpoint = pe + for _, key := range promptProjectEndpointEnvKeys { + if pe := strings.TrimSpace(env[key]); pe != "" { + settings.ProjectEndpoint = pe + break + } } } @@ -478,11 +565,14 @@ func (p *AgentServiceTargetProvider) waitForPromptAgentActive( // registerPromptAgentEnvVars stores the deployed prompt agent's identity and // harness invocation endpoint in the azd environment, mirroring the hosted // AGENT_{KEY}_* convention so downstream commands (show/invoke) resolve. +// bindings carries ids resolved by the deploy graph that must survive into the +// next deploy (currently the vector store id). func (p *AgentServiceTargetProvider) registerPromptAgentEnvVars( ctx context.Context, serviceConfig *azdext.ServiceConfig, agentName, version string, settings *PromptAgentSettings, + bindings map[string]any, ) error { if agentName == "" { return fmt.Errorf("agent name is empty; cannot register environment variables") @@ -495,6 +585,12 @@ func (p *AgentServiceTargetProvider) registerPromptAgentEnvVars( fmt.Sprintf("AGENT_%s_VERSION", serviceKey): version, fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey): endpoint, } + if storeID, ok := bindings[vectorStoreBindingKey].(string); ok && strings.TrimSpace(storeID) != "" { + envVars[fmt.Sprintf("AGENT_%s_VECTOR_STORE_ID", serviceKey)] = storeID + } + if storeName, ok := bindings[memoryStoreBindingKey].(string); ok && strings.TrimSpace(storeName) != "" { + envVars[fmt.Sprintf("AGENT_%s_MEMORY_STORE_NAME", serviceKey)] = storeName + } for key, value := range envVars { if _, err := p.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ @@ -530,7 +626,14 @@ func promptAgentResponsesEndpoint(settings *PromptAgentSettings) string { // azdEnvValues returns the current azd environment as a key/value map. Used to // overlay provisioned Foundry project values onto the prompt settings at // deploy time. +// +// It calls ensureEnv first: the prompt-agent branches of Endpoints and +// GetTargetResource return before ensureDeployContext runs, so p.env would +// otherwise still be nil and dereferencing it would panic the handler. func (p *AgentServiceTargetProvider) azdEnvValues(ctx context.Context) (map[string]string, error) { + if err := p.ensureEnv(ctx); err != nil { + return nil, err + } resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ Name: p.env.Name, }) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt_errors_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt_errors_test.go new file mode 100644 index 00000000000..cdd51bac549 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt_errors_test.go @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" +) + +// TestIsWorkspaceNotFoundError covers the classifier that decides whether the +// deploy path should fall back to creating the AML workspace. A false negative +// aborts the deploy; a false positive triggers a needless create. +func TestIsWorkspaceNotFoundError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "nil", err: nil, want: false}, + { + name: "response error code", + err: &azcore.ResponseError{ErrorCode: "WorkspaceNotFound", StatusCode: http.StatusNotFound}, + want: true, + }, + { + name: "response error code is case-insensitive", + err: &azcore.ResponseError{ErrorCode: "workspacenotfound"}, + want: true, + }, + { + name: "wrapped response error", + err: fmt.Errorf("resolving workspace: %w", + &azcore.ResponseError{ErrorCode: "WorkspaceNotFound"}), + want: true, + }, + {name: "message fallback", err: errors.New("the workspace not found in group rg"), want: true}, + {name: "unrelated 404", err: &azcore.ResponseError{StatusCode: http.StatusNotFound}, want: false}, + {name: "unrelated error", err: errors.New("forbidden"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isWorkspaceNotFoundError(tt.err); got != tt.want { + t.Errorf("isWorkspaceNotFoundError(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +// TestIsAgentConflictError covers the classifier that switches the publish path +// from create to new-version. Misclassifying here either fails a re-deploy or +// silently skips the create. +func TestIsAgentConflictError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "nil", err: nil, want: false}, + {name: "409 status", err: &azcore.ResponseError{StatusCode: http.StatusConflict}, want: true}, + {name: "conflict error code", err: &azcore.ResponseError{ErrorCode: "Conflict"}, want: true}, + { + name: "wrapped 409", + err: fmt.Errorf("creating agent: %w", &azcore.ResponseError{StatusCode: http.StatusConflict}), + want: true, + }, + {name: "message fallback", err: errors.New(`agent "x" already exists`), want: true}, + {name: "404 is not a conflict", err: &azcore.ResponseError{StatusCode: http.StatusNotFound}, want: false}, + {name: "unrelated error", err: errors.New("boom"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isAgentConflictError(tt.err); got != tt.want { + t.Errorf("isAgentConflictError(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/workspace_create.go b/cli/azd/extensions/azure.ai.agents/internal/project/workspace_create.go index c66e65041b3..ddc82fc7c73 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/workspace_create.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/workspace_create.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "errors" "fmt" + "net/http" "strings" "azureaiagent/internal/exterrors" @@ -156,6 +157,11 @@ func ensureStorageAccountForWorkspace( ) if _, err := client.GetByID(ctx, resourceID, storageAPIVersion, nil); err == nil { return resourceID, nil // already exists + } else if respErr, ok := errors.AsType[*azcore.ResponseError](err); !ok || + respErr.StatusCode != http.StatusNotFound { + // Only a 404 means "absent". Auth, throttling and transient network + // failures must not fall through to a create that masks the real cause. + return "", fmt.Errorf("checking storage account %q: %w", name, err) } skuName := "Standard_LRS" kind := "StorageV2" @@ -163,7 +169,7 @@ func ensureStorageAccountForWorkspace( Location: &location, Kind: &kind, SKU: &armresources.SKU{Name: &skuName}, - Properties: map[string]interface{}{ + Properties: map[string]any{ "supportsHttpsTrafficOnly": true, "accessTier": "Hot", }, @@ -194,13 +200,17 @@ func ensureKeyVaultForWorkspace( ) if _, err := client.GetByID(ctx, resourceID, keyVaultAPIVersion, nil); err == nil { return resourceID, nil // already exists + } else if respErr, ok := errors.AsType[*azcore.ResponseError](err); !ok || + respErr.StatusCode != http.StatusNotFound { + // Only a 404 means "absent" — see ensureStorageAccountForWorkspace. + return "", fmt.Errorf("checking key vault %q: %w", name, err) } body := armresources.GenericResource{ Location: &location, - Properties: map[string]interface{}{ - "sku": map[string]interface{}{"family": "A", "name": "standard"}, + Properties: map[string]any{ + "sku": map[string]any{"family": "A", "name": "standard"}, "tenantId": tenantID, - "accessPolicies": []interface{}{}, + "accessPolicies": []any{}, "enableSoftDelete": true, }, } @@ -226,7 +236,7 @@ func createAMLWorkspace( body := armresources.GenericResource{ Location: &location, Identity: &armresources.Identity{Type: &identityType}, - Properties: map[string]interface{}{ + Properties: map[string]any{ "storageAccount": storageID, "keyVault": kvID, }, diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go index 8c4e7d7baea..5121e6da61c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -186,6 +186,10 @@ type agentBlock struct { Kind string `yaml:"kind,omitempty"` Image string `yaml:"image,omitempty"` CodeConfiguration *codeConfigBlock `yaml:"codeConfiguration,omitempty"` + // PromptAgent is the prompt-agent harness settings block. Its presence is a + // structural marker that the service is a prompt agent, which matters because + // `azd ai agent init` does not write an explicit kind: into the service config. + PromptAgent *yaml.Node `yaml:"promptAgent,omitempty"` } // serviceBlock is the subset of a service entry we inspect for cross-service provisioning inputs. @@ -270,7 +274,17 @@ func Synthesize(in Input) (*Result, error) { if len(in.AcceptedHosts) > 0 && !slices.Contains(in.AcceptedHosts, svc.Host) { return nil, ErrServiceNotFound } - if strings.TrimSpace(svc.Endpoint) != "" { + // endpoint: is expanded before the emptiness test so a portable + // `endpoint: ${AZURE_AI_PROJECT_ENDPOINT}` collapses to "" (greenfield) when + // the variable is unset, instead of routing the caller down the brownfield + // path with an unresolvable literal. Expansion is unconditional: this is a + // control-flow decision, not a value the eject path writes out, so + // PreserveVarRefs must not change which branch is taken. + endpoint, err := expandEndpoint(svc.Endpoint, in.Env) + if err != nil { + return nil, err + } + if endpoint != "" { return nil, ErrEndpointBrownfield } @@ -376,12 +390,16 @@ func BrownfieldConnections( return collectConnections(root.Services, env, true, projectRoot) } -// ProjectEndpoint returns the endpoint configured on a Foundry project service. -// It resolves $ref includes before decoding the service body. +// ProjectEndpoint returns the endpoint configured on a Foundry project service, +// with ${VAR} references resolved from env (falling back to the process +// environment). It resolves $ref includes before decoding the service body. +// An endpoint whose variables are all unset resolves to "", matching how +// Synthesize treats it as greenfield. func ProjectEndpoint( raw []byte, serviceName string, projectRoot string, + env map[string]string, ) (string, error) { if len(raw) == 0 { return "", errors.New("synthesis: raw azure.yaml is empty") @@ -396,7 +414,18 @@ func ProjectEndpoint( if err != nil { return "", err } - return strings.TrimSpace(svc.Endpoint), nil + return expandEndpoint(svc.Endpoint, env) +} + +// expandEndpoint resolves ${VAR} in a project service's endpoint: and trims the +// result. Unset variables expand to the empty string, so a fully unresolved +// endpoint is indistinguishable from an absent one. +func expandEndpoint(raw string, env map[string]string) (string, error) { + expanded, err := maybeExpand(strings.TrimSpace(raw), env, true) + if err != nil { + return "", fmt.Errorf("expand endpoint: %w", err) + } + return strings.TrimSpace(expanded), nil } // loadProjectService decodes a service after resolving any local $ref includes. @@ -602,6 +631,9 @@ func deriveIncludeAcr( if agent.CodeConfiguration == nil { agent.CodeConfiguration = service.Config.CodeConfiguration } + if agent.PromptAgent == nil { + agent.PromptAgent = service.Config.PromptAgent + } } if agentNeedsAcr(agent) { return true, nil @@ -617,6 +649,13 @@ func agentNeedsAcr(a agentBlock) bool { if a.CodeConfiguration != nil || strings.TrimSpace(a.Image) != "" { return false } + // A promptAgent: block means Foundry runs the agent from its definition; there + // is nothing to build. `azd ai agent init` omits kind: from the service config, + // so without this check the default-to-hosted fallback below would provision an + // ACR (and an AcrPull role assignment) the prompt agent never uses. + if a.PromptAgent != nil { + return false + } // "hosted" is the only container kind; an empty kind defaults to hosted for // back-compat. Other explicit kinds (prompt, workflow) do not build. // NOTE: if a future non-container kind can omit kind:, replace this diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go index d505c04da2e..5d767022f44 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go @@ -920,6 +920,67 @@ func TestBrownfieldDeployments_EmptyRaw(t *testing.T) { require.Error(t, err) } +// TestSynthesize_EndpointVarReference covers the portable endpoint shape +// `azd ai agent init` writes: endpoint: ${AZURE_AI_PROJECT_ENDPOINT}. The +// variable is expanded before the brownfield test, so the same azure.yaml +// provisions a new project when the variable is unset and reuses an existing +// one when it is set. Without expansion the unset case would take the +// brownfield branch and then try to call a literal "${...}" URL. +func TestSynthesize_EndpointVarReference(t *testing.T) { + // Not parallel: the greenfield case pins AZURE_AI_PROJECT_ENDPOINT to empty + // via t.Setenv so a developer who exports it locally still sees the unset + // behavior (maybeExpand falls back to the process environment). + const yamlDoc = ` +services: + ai-project: + host: azure.ai.project + endpoint: ${AZURE_AI_PROJECT_ENDPOINT} +` + const endpoint = "https://acct.services.ai.azure.com/api/projects/p1" + + t.Run("unset variable is greenfield", func(t *testing.T) { + t.Setenv("AZURE_AI_PROJECT_ENDPOINT", "") + + res, err := Synthesize(Input{ + RawAzureYAML: []byte(yamlDoc), + ServiceName: "ai-project", + Env: map[string]string{}, + }) + require.NoError(t, err) + require.NotNil(t, res) + }) + + t.Run("set variable is brownfield", func(t *testing.T) { + _, err := Synthesize(Input{ + RawAzureYAML: []byte(yamlDoc), + ServiceName: "ai-project", + Env: map[string]string{"AZURE_AI_PROJECT_ENDPOINT": endpoint}, + }) + require.ErrorIs(t, err, ErrEndpointBrownfield) + }) + + t.Run("eject keeps the greenfield/brownfield split", func(t *testing.T) { + // PreserveVarRefs only governs the values written out, never which + // branch is taken, so eject must agree with provision. + _, err := Synthesize(Input{ + RawAzureYAML: []byte(yamlDoc), + ServiceName: "ai-project", + Env: map[string]string{"AZURE_AI_PROJECT_ENDPOINT": endpoint}, + PreserveVarRefs: true, + }) + require.ErrorIs(t, err, ErrEndpointBrownfield) + }) + + t.Run("ProjectEndpoint resolves the reference", func(t *testing.T) { + got, err := ProjectEndpoint( + []byte(yamlDoc), "ai-project", "", + map[string]string{"AZURE_AI_PROJECT_ENDPOINT": endpoint}, + ) + require.NoError(t, err) + require.Equal(t, endpoint, got) + }) +} + func TestBrownfieldDeployments_ResolvesFileRef( t *testing.T, ) { diff --git a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md index 7cf36a905ad..a496b5438fc 100644 --- a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## Unreleased + +### Bugs Fixed + +- A Foundry project service whose `endpoint:` is written as an environment reference (for example `endpoint: ${AZURE_AI_PROJECT_ENDPOINT}`, which `azd ai agent init` now generates so `azure.yaml` stays portable) is resolved against the azd environment before it is used. Previously the unexpanded `${...}` literal was carried into the brownfield deployment, which failed with `InvalidTemplate` because the project name segment came out empty. An endpoint whose variables are all unset now means "create a new project" instead of "reuse an existing one". + ## 1.0.0-beta.3 (2026-07-23) ### Features Added diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go index b34a97ba7fd..e048db342e8 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go @@ -179,6 +179,7 @@ func (p *FoundryProvisioningProvider) Initialize( rawYAML, projectPath, svcName, + p.networkEnvMap(ctx), ) if endpointErr != nil { return exterrors.Validation( @@ -237,6 +238,7 @@ func (p *FoundryProvisioningProvider) Initialize( rawYAML, projectPath, svcName, + p.networkEnvMap(ctx), ) if endpointErr != nil { return exterrors.Validation( @@ -296,20 +298,30 @@ func (p *FoundryProvisioningProvider) networkEnvMap(ctx context.Context) map[str log.Printf("[debug] foundry provider: no azd client; network ${VAR} uses process env only") return nil } - envClient := p.azdClient.Environment() + return azdEnvMap(ctx, p.azdClient) +} + +// azdEnvMap returns a best-effort name -> value map of the current azd +// environment. On any failure it returns nil and callers fall back to the +// process environment. +func azdEnvMap(ctx context.Context, azdClient *azdext.AzdClient) map[string]string { + if azdClient == nil { + return nil + } + envClient := azdClient.Environment() if envClient == nil { - log.Printf("[debug] foundry provider: no environment client; network ${VAR} uses process env only") + log.Printf("[debug] foundry provider: no environment client; ${VAR} uses process env only") return nil } curr, err := envClient.GetCurrent(ctx, &azdext.EmptyRequest{}) if err != nil || curr.GetEnvironment() == nil { log.Printf("[debug] foundry provider: no current azd environment (%v); "+ - "network ${VAR} uses process env only", err) + "${VAR} uses process env only", err) return nil } resp, err := envClient.GetValues(ctx, &azdext.GetEnvironmentRequest{Name: curr.GetEnvironment().GetName()}) if err != nil { - log.Printf("[debug] foundry provider: GetValues failed (%s); network ${VAR} uses process env only", err) + log.Printf("[debug] foundry provider: GetValues failed (%s); ${VAR} uses process env only", err) return nil } out := make(map[string]string, len(resp.GetKeyValues())) @@ -373,10 +385,18 @@ func (p *FoundryProvisioningProvider) onDiskTemplatePresent() bool { fileExistsAt(filepath.Join(infraDir, onDiskBicepFile)) } +// foundryServiceEndpointAtRoot returns the endpoint: declared on a Foundry +// project service, with ${VAR} references resolved from env (falling back to +// the process environment). Callers must pass the azd environment: the endpoint +// is normally written as ${AZURE_AI_PROJECT_ENDPOINT}, and every brownfield +// consumer parses the account and project names out of this value, so returning +// the raw reference would build an ARM template with empty name segments. +// An endpoint whose variables are all unset resolves to "" (greenfield). func foundryServiceEndpointAtRoot( rawYAML []byte, projectRoot string, svcName string, + env map[string]string, ) (string, error) { type svc struct { Endpoint string `yaml:"endpoint,omitempty"` @@ -410,7 +430,21 @@ func foundryServiceEndpointAtRoot( if err := yaml.Unmarshal(data, &service); err != nil { return "", err } - return strings.TrimSpace(service.Endpoint), nil + endpoint := strings.TrimSpace(service.Endpoint) + if endpoint == "" { + return "", nil + } + expanded, err := foundry.ExpandEnv(endpoint, func(name string) string { + if v, ok := env[name]; ok { + return v + } + v, _ := os.LookupEnv(name) + return v + }) + if err != nil { + return "", fmt.Errorf("expand endpoint: %w", err) + } + return strings.TrimSpace(expanded), nil } // resolveEnvName resolves just the active azd environment name. The brownfield diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go index 4eb58ccd033..5d6c5de45a2 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go @@ -902,6 +902,7 @@ services: []byte(tt.yaml), "", tt.svcName, + nil, ) if tt.wantErr { require.Error(t, err) @@ -937,6 +938,7 @@ func TestFoundryServiceEndpointAtRoot_ResolvesFileRef( raw, root, "foundry", + nil, ) require.NoError(t, err) @@ -947,6 +949,42 @@ func TestFoundryServiceEndpointAtRoot_ResolvesFileRef( ) } +// TestFoundryServiceEndpointAtRoot_ExpandsEnvRef covers the portable form that +// `azd ai agent init` writes. Returning the raw ${VAR} literal used to flow +// straight into the brownfield ARM template, where projectNameFromEndpoint +// found no /api/projects/ suffix and the deployment failed with an +// invalid two-segment resource name (/). +func TestFoundryServiceEndpointAtRoot_ExpandsEnvRef(t *testing.T) { + // Not parallel: the greenfield case pins AZURE_AI_PROJECT_ENDPOINT to empty + // via t.Setenv so a developer who exports it locally still sees the unset + // behavior, and t.Setenv is incompatible with t.Parallel. + const endpoint = "https://acct.services.ai.azure.com/api/projects/my-project" + raw := []byte(`services: + foundry: + host: azure.ai.project + endpoint: ${AZURE_AI_PROJECT_ENDPOINT} +`) + + t.Run("set variable resolves to the real endpoint", func(t *testing.T) { + got, err := foundryServiceEndpointAtRoot(raw, "", "foundry", map[string]string{ + "AZURE_AI_PROJECT_ENDPOINT": endpoint, + }) + + require.NoError(t, err) + assert.Equal(t, endpoint, got) + assert.Equal(t, "my-project", projectNameFromEndpoint(got)) + }) + + t.Run("unset variable is greenfield", func(t *testing.T) { + t.Setenv("AZURE_AI_PROJECT_ENDPOINT", "") + + got, err := foundryServiceEndpointAtRoot(raw, "", "foundry", nil) + + require.NoError(t, err) + assert.Empty(t, got) + }) +} + func TestProjectNameFromEndpoint(t *testing.T) { t.Parallel() assert.Equal(t, "my-project", projectNameFromEndpoint( diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go index 5f49d8f0f92..7bcd53dd68b 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go @@ -285,6 +285,7 @@ func (c *ResourceGroupLocationCheck) isBrownfieldFoundryProject(ctx context.Cont rawYAML, projectPath, svcName, + azdEnvMap(ctx, c.azdClient), ) return err == nil && endpoint != "" } diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go index 8c4e7d7baea..5121e6da61c 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -186,6 +186,10 @@ type agentBlock struct { Kind string `yaml:"kind,omitempty"` Image string `yaml:"image,omitempty"` CodeConfiguration *codeConfigBlock `yaml:"codeConfiguration,omitempty"` + // PromptAgent is the prompt-agent harness settings block. Its presence is a + // structural marker that the service is a prompt agent, which matters because + // `azd ai agent init` does not write an explicit kind: into the service config. + PromptAgent *yaml.Node `yaml:"promptAgent,omitempty"` } // serviceBlock is the subset of a service entry we inspect for cross-service provisioning inputs. @@ -270,7 +274,17 @@ func Synthesize(in Input) (*Result, error) { if len(in.AcceptedHosts) > 0 && !slices.Contains(in.AcceptedHosts, svc.Host) { return nil, ErrServiceNotFound } - if strings.TrimSpace(svc.Endpoint) != "" { + // endpoint: is expanded before the emptiness test so a portable + // `endpoint: ${AZURE_AI_PROJECT_ENDPOINT}` collapses to "" (greenfield) when + // the variable is unset, instead of routing the caller down the brownfield + // path with an unresolvable literal. Expansion is unconditional: this is a + // control-flow decision, not a value the eject path writes out, so + // PreserveVarRefs must not change which branch is taken. + endpoint, err := expandEndpoint(svc.Endpoint, in.Env) + if err != nil { + return nil, err + } + if endpoint != "" { return nil, ErrEndpointBrownfield } @@ -376,12 +390,16 @@ func BrownfieldConnections( return collectConnections(root.Services, env, true, projectRoot) } -// ProjectEndpoint returns the endpoint configured on a Foundry project service. -// It resolves $ref includes before decoding the service body. +// ProjectEndpoint returns the endpoint configured on a Foundry project service, +// with ${VAR} references resolved from env (falling back to the process +// environment). It resolves $ref includes before decoding the service body. +// An endpoint whose variables are all unset resolves to "", matching how +// Synthesize treats it as greenfield. func ProjectEndpoint( raw []byte, serviceName string, projectRoot string, + env map[string]string, ) (string, error) { if len(raw) == 0 { return "", errors.New("synthesis: raw azure.yaml is empty") @@ -396,7 +414,18 @@ func ProjectEndpoint( if err != nil { return "", err } - return strings.TrimSpace(svc.Endpoint), nil + return expandEndpoint(svc.Endpoint, env) +} + +// expandEndpoint resolves ${VAR} in a project service's endpoint: and trims the +// result. Unset variables expand to the empty string, so a fully unresolved +// endpoint is indistinguishable from an absent one. +func expandEndpoint(raw string, env map[string]string) (string, error) { + expanded, err := maybeExpand(strings.TrimSpace(raw), env, true) + if err != nil { + return "", fmt.Errorf("expand endpoint: %w", err) + } + return strings.TrimSpace(expanded), nil } // loadProjectService decodes a service after resolving any local $ref includes. @@ -602,6 +631,9 @@ func deriveIncludeAcr( if agent.CodeConfiguration == nil { agent.CodeConfiguration = service.Config.CodeConfiguration } + if agent.PromptAgent == nil { + agent.PromptAgent = service.Config.PromptAgent + } } if agentNeedsAcr(agent) { return true, nil @@ -617,6 +649,13 @@ func agentNeedsAcr(a agentBlock) bool { if a.CodeConfiguration != nil || strings.TrimSpace(a.Image) != "" { return false } + // A promptAgent: block means Foundry runs the agent from its definition; there + // is nothing to build. `azd ai agent init` omits kind: from the service config, + // so without this check the default-to-hosted fallback below would provision an + // ACR (and an AcrPull role assignment) the prompt agent never uses. + if a.PromptAgent != nil { + return false + } // "hosted" is the only container kind; an empty kind defaults to hosted for // back-compat. Other explicit kinds (prompt, workflow) do not build. // NOTE: if a future non-container kind can omit kind:, replace this diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go index 04f90f496f7..ee870e4b224 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go @@ -129,6 +129,34 @@ services: wantDeployLen: 0, wantIncludeAcr: false, }, + { + // `azd ai agent init` writes a promptAgent: block under config: and + // omits kind:, so the promptAgent block is the only marker that this + // service is not a container agent. + name: "sibling prompt agent without kind => no ACR", + yaml: ` +services: + my-prompt-agent: + host: azure.ai.agent + project: . + config: + promptAgent: + apiVersion: v1 + baseUrl: https://ai.azure.com/api + resourceGroup: my-rg + subscriptionId: 00000000-0000-0000-0000-000000000000 + workspace: my-workspace + my-project: + host: azure.ai.project + deployments: + - name: gpt-4o-mini + model: {format: OpenAI, name: gpt-4o-mini, version: "2024-07-18"} + sku: {capacity: 50, name: GlobalStandard} +`, + serviceName: "my-project", + wantDeployLen: 1, + wantIncludeAcr: false, + }, { name: "greenfield hosted agent runtime-only (no docker) => ACR on", yaml: ` @@ -959,7 +987,7 @@ services: host: azure.ai.connection $ref: ./connection.yaml ` - endpoint, err := ProjectEndpoint([]byte(yaml), "project", root) + endpoint, err := ProjectEndpoint([]byte(yaml), "project", root, nil) require.NoError(t, err) assert.Equal( t, From 363b1f1ad68fb058a1eddbf8b4c9b5cf12fa70a5 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Fri, 21 Aug 2026 21:24:59 +0530 Subject: [PATCH 16/24] 0.1.50-preview for demo --- .../extensions/azure.ai.agents/extension.yaml | 9 +- .../internal/cmd/prompt_service.go | 17 +- .../internal/project/prompt_client_test.go | 48 +++ .../internal/project/service_target_prompt.go | 21 +- .../extensions/azure.ai.agents/version.txt | 2 +- cli/azd/extensions/registry.json | 380 +++++++++++------- 6 files changed, 322 insertions(+), 155 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index 49c4d9b1f4d..4b3b6570085 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -5,8 +5,13 @@ displayName: Foundry agents (Beta) description: Ship agents with Microsoft Foundry from your terminal. (Beta) usage: azd ai agent [options] # NOTE: Make sure version.txt is in sync with this version. -version: 0.1.46-preview -requiredAzdVersion: ">1.25.2" +version: 0.1.50-preview +# Must be >= the requiredAzdVersion of every dependency below. azure.ai.projects +# ~1.0.0-beta.3 owns the microsoft.foundry provisioning provider and requires +# azd >= 1.27.1; a lower floor here lets azd install this extension and then +# silently resolve a provider-less azure.ai.projects, so `azd up` fails with +# "no concrete found for: provisioning.Provider". +requiredAzdVersion: ">=1.27.1" dependencies: - id: azure.ai.inspector version: "~1.0.0-beta.1" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go index 23851844c58..0008910535a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go @@ -65,10 +65,21 @@ func resolvePromptAgentService( if !ok { return nil, false, nil } - settings.ApplyEnvOverrides() - if err := settings.Validate(); err != nil { + + // Resolve the block exactly as deploy does. azure.yaml carries ${VAR} + // references so the project stays portable, so the raw config holds literal + // "${AZURE_AI_PROJECT_ENDPOINT}" strings; without expansion these commands + // fail with "is not a valid absolute URL" instead of reaching the harness. + // The azd environment is best-effort — when it cannot be read, expansion + // falls back to the process environment and unset references collapse to the + // defaults, which is what lets these commands run in a project that has not + // been provisioned yet. + envValues, envErr := promptEnvValues(ctx, azdClient) + resolved, err := project.ResolvePromptAgentSettings(settings, envValues) + if err != nil { return nil, false, err } + settings = resolved // Apply the same azd environment-derived target resolution that deploy uses // so lifecycle commands (show/invoke/list/delete) hit the identical managed @@ -76,7 +87,7 @@ func resolvePromptAgentService( // this, these commands resolve promptAgent.workspace from azure.yaml verbatim // and query a non-existent workspace, yielding an HTML 404 the client cannot // parse. - if envValues, envErr := promptEnvValues(ctx, azdClient); envErr == nil { + if envErr == nil { if _, mapErr := project.ResolvePromptTargetFromEnv(settings, envValues); mapErr != nil { return nil, false, mapErr } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go index 8030da6d542..4bbc168d225 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go @@ -179,6 +179,54 @@ func TestExpandPromptAgentSettings(t *testing.T) { }) } +// TestResolvePromptAgentSettings asserts the shared resolution deploy and the +// lifecycle commands (show/invoke/list/delete) both run: ${VAR} references are +// expanded before anything consumes them, and unset fields fall back to the +// package defaults. The projectEndpoint case is a regression guard — leaving it +// unexpanded surfaced as `projectEndpoint "${AZURE_AI_PROJECT_ENDPOINT}" is not +// a valid absolute URL` once the client tried to split it. +func TestResolvePromptAgentSettings(t *testing.T) { + const endpoint = "https://acct.services.ai.azure.com/api/projects/p1" + + t.Run("expands references and applies defaults", func(t *testing.T) { + got, err := ResolvePromptAgentSettings(&PromptAgentSettings{ + SubscriptionID: "${AZURE_SUBSCRIPTION_ID}", + ResourceGroup: "${AZURE_RESOURCE_GROUP}", + ProjectEndpoint: "${AZURE_AI_PROJECT_ENDPOINT}", + }, map[string]string{ + "AZURE_SUBSCRIPTION_ID": "sub-1", + "AZURE_RESOURCE_GROUP": "rg-1", + "AZURE_AI_PROJECT_ENDPOINT": endpoint, + }) + + require.NoError(t, err) + assert.Equal(t, endpoint, got.ProjectEndpoint) + assert.Equal(t, "sub-1", got.SubscriptionID) + assert.Equal(t, "rg-1", got.ResourceGroup) + // Not configured, so the defaults must survive the overlay. + assert.Equal(t, DefaultPromptBaseURL, got.BaseURL) + assert.Equal(t, DefaultPromptWorkspace, got.Workspace) + assert.Equal(t, DefaultPromptAPIVersion, got.EffectiveAPIVersion()) + }) + + t.Run("literal values are preserved", func(t *testing.T) { + got, err := ResolvePromptAgentSettings(&PromptAgentSettings{ + ProjectEndpoint: endpoint, + }, nil) + + require.NoError(t, err) + assert.Equal(t, endpoint, got.ProjectEndpoint) + }) + + t.Run("nil config yields the defaults", func(t *testing.T) { + got, err := ResolvePromptAgentSettings(nil, nil) + + require.NoError(t, err) + assert.Equal(t, DefaultPromptBaseURL, got.BaseURL) + assert.Equal(t, DefaultPromptWorkspace, got.Workspace) + }) +} + // TestNewPromptAgentClient_BuildsClient asserts a client builds from valid // settings (no-auth path to avoid requiring an Azure login in tests). func TestNewPromptAgentClient_BuildsClient(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go index 1012cb62528..f5351c6b2be 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go @@ -68,12 +68,29 @@ func (p *AgentServiceTargetProvider) promptAgentSettings(env map[string]string) "check the service configuration in azure.yaml", ) } - configured, err := expandPromptAgentSettings(cfg.PromptAgent, env) + return ResolvePromptAgentSettings(cfg.PromptAgent, env) +} + +// ResolvePromptAgentSettings turns a raw promptAgent block from azure.yaml into +// settings that can address the harness: ${VAR} references are expanded against +// env, the result is layered over the defaults, process-environment overrides +// are applied, and the whole is validated. +// +// Every caller that talks to the harness must go through this. The block is +// written with ${...} references so azure.yaml stays portable, which means the +// raw config carries literal "${AZURE_AI_PROJECT_ENDPOINT}" strings — usable as +// a URL only after expansion. Skipping this step fails at the point of use with +// a message about a malformed URL rather than a missing variable. +func ResolvePromptAgentSettings( + configured *PromptAgentSettings, + env map[string]string, +) (*PromptAgentSettings, error) { + expanded, err := expandPromptAgentSettings(configured, env) if err != nil { return nil, err } settings := DefaultPromptAgentSettings() - settings.overlay(configured) + settings.overlay(expanded) settings.ApplyEnvOverrides() if err := settings.Validate(); err != nil { return nil, err diff --git a/cli/azd/extensions/azure.ai.agents/version.txt b/cli/azd/extensions/azure.ai.agents/version.txt index 393ad52c480..90cfc6850c0 100644 --- a/cli/azd/extensions/azure.ai.agents/version.txt +++ b/cli/azd/extensions/azure.ai.agents/version.txt @@ -1 +1 @@ -0.1.46-preview +0.1.50-preview diff --git a/cli/azd/extensions/registry.json b/cli/azd/extensions/registry.json index 26cd8a6b5c4..0503fca1b5f 100644 --- a/cli/azd/extensions/registry.json +++ b/cli/azd/extensions/registry.json @@ -12,11 +12,11 @@ "custom-commands", "lifecycle-events" ], - "usage": "azd demo \u003ccommand\u003e [options]", + "usage": "azd demo [options]", "examples": [ { "name": "context", - "description": "Displays the current `azd` project \u0026 environment context.", + "description": "Displays the current `azd` project & environment context.", "usage": "azd demo context" }, { @@ -83,11 +83,11 @@ "lifecycle-events", "mcp-server" ], - "usage": "azd demo \u003ccommand\u003e [options]", + "usage": "azd demo [options]", "examples": [ { "name": "context", - "description": "Displays the current `azd` project \u0026 environment context.", + "description": "Displays the current `azd` project & environment context.", "usage": "azd demo context" }, { @@ -168,11 +168,11 @@ "description": "Deploys application components to demo" } ], - "usage": "azd demo \u003ccommand\u003e [options]", + "usage": "azd demo [options]", "examples": [ { "name": "context", - "description": "Displays the current `azd` project \u0026 environment context.", + "description": "Displays the current `azd` project & environment context.", "usage": "azd demo context" }, { @@ -254,11 +254,11 @@ "description": "Deploys application components to demo" } ], - "usage": "azd demo \u003ccommand\u003e [options]", + "usage": "azd demo [options]", "examples": [ { "name": "context", - "description": "Displays the current `azd` project \u0026 environment context.", + "description": "Displays the current `azd` project & environment context.", "usage": "azd demo context" }, { @@ -340,11 +340,11 @@ "description": "Deploys application components to demo" } ], - "usage": "azd demo \u003ccommand\u003e [options]", + "usage": "azd demo [options]", "examples": [ { "name": "context", - "description": "Displays the current `azd` project \u0026 environment context.", + "description": "Displays the current `azd` project & environment context.", "usage": "azd demo context" }, { @@ -663,7 +663,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd x \u003ccommand\u003e [options]", + "usage": "azd x [options]", "examples": [ { "name": "init", @@ -747,7 +747,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd x \u003ccommand\u003e [options]", + "usage": "azd x [options]", "examples": [ { "name": "init", @@ -836,7 +836,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd x \u003ccommand\u003e [options]", + "usage": "azd x [options]", "examples": [ { "name": "init", @@ -925,7 +925,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd x \u003ccommand\u003e [options]", + "usage": "azd x [options]", "examples": [ { "name": "init", @@ -1014,7 +1014,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd x \u003ccommand\u003e [options]", + "usage": "azd x [options]", "examples": [ { "name": "init", @@ -1103,7 +1103,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd x \u003ccommand\u003e [options]", + "usage": "azd x [options]", "examples": [ { "name": "init", @@ -1193,7 +1193,7 @@ "custom-commands", "metadata" ], - "usage": "azd x \u003ccommand\u003e [options]", + "usage": "azd x [options]", "examples": [ { "name": "init", @@ -1283,7 +1283,7 @@ "custom-commands", "metadata" ], - "usage": "azd x \u003ccommand\u003e [options]", + "usage": "azd x [options]", "examples": [ { "name": "init", @@ -1373,7 +1373,7 @@ "custom-commands", "metadata" ], - "usage": "azd x \u003ccommand\u003e [options]", + "usage": "azd x [options]", "examples": [ { "name": "init", @@ -1463,7 +1463,7 @@ "custom-commands", "metadata" ], - "usage": "azd x \u003ccommand\u003e [options]", + "usage": "azd x [options]", "examples": [ { "name": "init", @@ -1553,7 +1553,7 @@ "custom-commands", "metadata" ], - "usage": "azd x \u003ccommand\u003e [options]", + "usage": "azd x [options]", "examples": [ { "name": "init", @@ -1650,7 +1650,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd coding-agent \u003ccommand\u003e [options]", + "usage": "azd coding-agent [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -1708,7 +1708,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd coding-agent \u003ccommand\u003e [options]", + "usage": "azd coding-agent [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -1766,7 +1766,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd coding-agent \u003ccommand\u003e [options]", + "usage": "azd coding-agent [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -1825,7 +1825,7 @@ "custom-commands", "metadata" ], - "usage": "azd coding-agent \u003ccommand\u003e [options]", + "usage": "azd coding-agent [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -1884,7 +1884,7 @@ "custom-commands", "metadata" ], - "usage": "azd coding-agent \u003ccommand\u003e [options]", + "usage": "azd coding-agent [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -1960,7 +1960,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -2034,7 +2034,7 @@ "description": "Deploys agents to the Foundry Agent Service." } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -2108,7 +2108,7 @@ "description": "Deploys agents to the Foundry Agent Service." } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -2182,7 +2182,7 @@ "description": "Deploys agents to the Foundry Agent Service." } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -2256,7 +2256,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -2330,7 +2330,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -2404,7 +2404,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -2478,7 +2478,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -2553,7 +2553,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -2628,7 +2628,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -2703,7 +2703,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -2764,7 +2764,7 @@ }, { "version": "0.1.10-preview", - "requiredAzdVersion": "\u003e1.23.4", + "requiredAzdVersion": ">1.23.4", "capabilities": [ "custom-commands", "lifecycle-events", @@ -2779,7 +2779,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -2840,7 +2840,7 @@ }, { "version": "0.1.11-preview", - "requiredAzdVersion": "\u003e1.23.4", + "requiredAzdVersion": ">1.23.4", "capabilities": [ "custom-commands", "lifecycle-events", @@ -2855,7 +2855,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -2916,7 +2916,7 @@ }, { "version": "0.1.12-preview", - "requiredAzdVersion": "\u003e1.23.6", + "requiredAzdVersion": ">1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -2931,7 +2931,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -2992,7 +2992,7 @@ }, { "version": "0.1.13-preview", - "requiredAzdVersion": "\u003e1.23.6", + "requiredAzdVersion": ">1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3007,7 +3007,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -3068,7 +3068,7 @@ }, { "version": "0.1.14-preview", - "requiredAzdVersion": "\u003e1.23.6", + "requiredAzdVersion": ">1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3083,7 +3083,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -3144,7 +3144,7 @@ }, { "version": "0.1.15-preview", - "requiredAzdVersion": "\u003e1.23.6", + "requiredAzdVersion": ">1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3159,7 +3159,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -3220,7 +3220,7 @@ }, { "version": "0.1.16-preview", - "requiredAzdVersion": "\u003e1.23.6", + "requiredAzdVersion": ">1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3235,7 +3235,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -3296,7 +3296,7 @@ }, { "version": "0.1.17-preview", - "requiredAzdVersion": "\u003e1.23.6", + "requiredAzdVersion": ">1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3311,7 +3311,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -3372,7 +3372,7 @@ }, { "version": "0.1.18-preview", - "requiredAzdVersion": "\u003e1.23.6", + "requiredAzdVersion": ">1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3387,7 +3387,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -3448,7 +3448,7 @@ }, { "version": "0.1.19-preview", - "requiredAzdVersion": "\u003e1.23.6", + "requiredAzdVersion": ">1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3463,7 +3463,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -3524,7 +3524,7 @@ }, { "version": "0.1.20-preview", - "requiredAzdVersion": "\u003e1.23.6", + "requiredAzdVersion": ">1.23.6", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3539,7 +3539,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -3600,7 +3600,7 @@ }, { "version": "0.1.21-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3615,7 +3615,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -3676,7 +3676,7 @@ }, { "version": "0.1.22-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3691,7 +3691,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -3752,7 +3752,7 @@ }, { "version": "0.1.23-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3767,7 +3767,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -3828,7 +3828,7 @@ }, { "version": "0.1.24-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3843,7 +3843,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -3904,7 +3904,7 @@ }, { "version": "0.1.25-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3919,7 +3919,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -3980,7 +3980,7 @@ }, { "version": "0.1.26-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -3995,7 +3995,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -4056,7 +4056,7 @@ }, { "version": "0.1.27-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4071,7 +4071,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -4132,7 +4132,7 @@ }, { "version": "0.1.28-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4147,7 +4147,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -4208,7 +4208,7 @@ }, { "version": "0.1.29-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4223,7 +4223,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -4284,7 +4284,7 @@ }, { "version": "0.1.30-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4299,7 +4299,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -4360,7 +4360,7 @@ }, { "version": "0.1.31-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4375,7 +4375,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -4436,7 +4436,7 @@ }, { "version": "0.1.32-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4451,7 +4451,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -4512,7 +4512,7 @@ }, { "version": "0.1.33-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4527,7 +4527,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -4588,7 +4588,7 @@ }, { "version": "0.1.34-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4603,7 +4603,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -4664,7 +4664,7 @@ }, { "version": "0.1.35-preview", - "requiredAzdVersion": "\u003e1.25.2", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4679,7 +4679,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -4746,7 +4746,7 @@ }, { "version": "0.1.36-preview", - "requiredAzdVersion": "\u003e1.25.2", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4761,7 +4761,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -4828,7 +4828,7 @@ }, { "version": "0.1.37-preview", - "requiredAzdVersion": "\u003e1.25.2", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4843,7 +4843,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -4910,7 +4910,7 @@ }, { "version": "0.1.38-preview", - "requiredAzdVersion": "\u003e1.25.2", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -4925,7 +4925,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -4992,7 +4992,7 @@ }, { "version": "0.1.39-preview", - "requiredAzdVersion": "\u003e1.25.2", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -5007,7 +5007,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -5074,7 +5074,7 @@ }, { "version": "0.1.40-preview", - "requiredAzdVersion": "\u003e1.25.2", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -5089,7 +5089,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -5156,7 +5156,7 @@ }, { "version": "0.1.41-preview", - "requiredAzdVersion": "\u003e1.25.2", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -5171,7 +5171,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -5238,7 +5238,7 @@ }, { "version": "0.1.42-preview", - "requiredAzdVersion": "\u003e1.25.2", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -5253,7 +5253,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -5320,7 +5320,7 @@ }, { "version": "0.1.43-preview", - "requiredAzdVersion": "\u003e1.25.2", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -5335,7 +5335,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -5402,7 +5402,7 @@ }, { "version": "0.1.44-preview", - "requiredAzdVersion": "\u003e1.25.2", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -5417,7 +5417,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -5484,7 +5484,7 @@ }, { "version": "0.1.45-preview", - "requiredAzdVersion": "\u003e1.25.2", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -5499,7 +5499,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -5566,7 +5566,7 @@ }, { "version": "0.1.46-preview", - "requiredAzdVersion": "\u003e1.25.2", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", @@ -5581,7 +5581,7 @@ "description": "Deploys agents to the Foundry Agent Service" } ], - "usage": "azd ai agent \u003ccommand\u003e [options]", + "usage": "azd ai agent [options]", "examples": [ { "name": "init", @@ -5645,6 +5645,92 @@ "version": "~0.0.1-preview" } ] + }, + { + "version": "0.1.50-preview", + "requiredAzdVersion": ">=1.27.1", + "capabilities": [ + "custom-commands", + "lifecycle-events", + "mcp-server", + "service-target-provider", + "metadata" + ], + "providers": [ + { + "name": "azure.ai.agent", + "type": "service-target", + "description": "Deploys agents to the Foundry Agent Service" + } + ], + "usage": "azd ai agent [options]", + "examples": [ + { + "name": "init", + "description": "Initialize a new AI agent project.", + "usage": "azd ai agent init" + } + ], + "artifacts": { + "darwin/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "1f8de12485a8d70c18a00d0e16449745c47b2d179f7d189c432b3ff36b589c76" + }, + "entryPoint": "azure-ai-agents-darwin-amd64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.50-preview/azure-ai-agents-darwin-amd64.zip" + }, + "darwin/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "81ae1474732733f541f50a37d452719580fb560c446c3dd9d32af3d5a1837528" + }, + "entryPoint": "azure-ai-agents-darwin-arm64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.50-preview/azure-ai-agents-darwin-arm64.zip" + }, + "linux/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "02cb6ab8484b83700e870e419f4c64a3d8688c84211b00cbd871815a26cec8a6" + }, + "entryPoint": "azure-ai-agents-linux-amd64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.50-preview/azure-ai-agents-linux-amd64.tar.gz" + }, + "linux/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "86c6b668154d27d814071e761dc756415a36b31604bafc45b77855848de8d874" + }, + "entryPoint": "azure-ai-agents-linux-arm64", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.50-preview/azure-ai-agents-linux-arm64.tar.gz" + }, + "windows/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "81efb225e212bc4e26f6afb2f0fe6d0aa5d109c2081affe76ac6c5e4deddd50e" + }, + "entryPoint": "azure-ai-agents-windows-amd64.exe", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.50-preview/azure-ai-agents-windows-amd64.zip" + }, + "windows/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "740cf97aaa3fc96457e39a4cef0cbf31b5ceddb8a07dd551f9196505f9a86720" + }, + "entryPoint": "azure-ai-agents-windows-arm64.exe", + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.50-preview/azure-ai-agents-windows-arm64.zip" + } + }, + "dependencies": [ + { + "id": "azure.ai.inspector", + "version": "~1.0.0-beta.1" + }, + { + "id": "azure.ai.projects", + "version": "~1.0.0-beta.3" + } + ] } ] }, @@ -5659,7 +5745,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd concurx \u003ccommand\u003e [options]", + "usage": "azd concurx [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -5718,7 +5804,7 @@ "custom-commands", "metadata" ], - "usage": "azd concurx \u003ccommand\u003e [options]", + "usage": "azd concurx [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -5777,7 +5863,7 @@ "custom-commands", "metadata" ], - "usage": "azd concurx \u003ccommand\u003e [options]", + "usage": "azd concurx [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -5843,7 +5929,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd ai finetuning \u003ccommand\u003e [options]", + "usage": "azd ai finetuning [options]", "examples": [ { "name": "init", @@ -5912,7 +5998,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd ai finetuning \u003ccommand\u003e [options]", + "usage": "azd ai finetuning [options]", "examples": [ { "name": "init", @@ -5982,7 +6068,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning \u003ccommand\u003e [options]", + "usage": "azd ai finetuning [options]", "examples": [ { "name": "init", @@ -6052,7 +6138,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning \u003ccommand\u003e [options]", + "usage": "azd ai finetuning [options]", "examples": [ { "name": "init", @@ -6122,7 +6208,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning \u003ccommand\u003e [options]", + "usage": "azd ai finetuning [options]", "examples": [ { "name": "init", @@ -6192,7 +6278,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning \u003ccommand\u003e [options]", + "usage": "azd ai finetuning [options]", "examples": [ { "name": "init", @@ -6262,7 +6348,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning \u003ccommand\u003e [options]", + "usage": "azd ai finetuning [options]", "examples": [ { "name": "init", @@ -6332,7 +6418,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning \u003ccommand\u003e [options]", + "usage": "azd ai finetuning [options]", "examples": [ { "name": "init", @@ -6410,7 +6496,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models \u003ccommand\u003e [options]", + "usage": "azd ai models [options]", "examples": [ { "name": "init", @@ -6495,7 +6581,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models \u003ccommand\u003e [options]", + "usage": "azd ai models [options]", "examples": [ { "name": "init", @@ -6580,7 +6666,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models \u003ccommand\u003e [options]", + "usage": "azd ai models [options]", "examples": [ { "name": "init", @@ -6665,7 +6751,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models \u003ccommand\u003e [options]", + "usage": "azd ai models [options]", "examples": [ { "name": "init", @@ -6750,7 +6836,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models \u003ccommand\u003e [options]", + "usage": "azd ai models [options]", "examples": [ { "name": "init", @@ -6840,7 +6926,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models \u003ccommand\u003e [options]", + "usage": "azd ai models [options]", "examples": [ { "name": "init", @@ -6938,12 +7024,12 @@ "custom-commands", "metadata" ], - "usage": "azd appservice \u003ccommand\u003e [options]", + "usage": "azd appservice [options]", "examples": [ { "name": "swap", "description": "Swap deployment slots for an App Service.", - "usage": "azd appservice swap --service \u003cservice-name\u003e --src \u003csource-slot\u003e --dst \u003cdestination-slot\u003e" + "usage": "azd appservice swap --service --src --dst " } ], "artifacts": { @@ -7003,12 +7089,12 @@ "custom-commands", "metadata" ], - "usage": "azd appservice \u003ccommand\u003e [options]", + "usage": "azd appservice [options]", "examples": [ { "name": "swap", "description": "Swap deployment slots for an App Service.", - "usage": "azd appservice swap --service \u003cservice-name\u003e --src \u003csource-slot\u003e --dst \u003cdestination-slot\u003e" + "usage": "azd appservice swap --service --src --dst " } ], "artifacts": { @@ -7072,12 +7158,12 @@ "versions": [ { "version": "0.0.1-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "metadata" ], - "usage": "azd ai inspector \u003ccommand\u003e [options]", + "usage": "azd ai inspector [options]", "examples": [ { "name": "launch", @@ -7292,7 +7378,7 @@ "versions": [ { "version": "0.1.0-preview", - "requiredAzdVersion": "\u003e1.25.2", + "requiredAzdVersion": ">1.25.2", "usage": "", "examples": null, "dependencies": [ @@ -7416,7 +7502,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai connection \u003ccommand\u003e [options]", + "usage": "azd ai connection [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -7475,7 +7561,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai connection \u003ccommand\u003e [options]", + "usage": "azd ai connection [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -7534,7 +7620,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai connection \u003ccommand\u003e [options]", + "usage": "azd ai connection [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -7809,7 +7895,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai project \u003ccommand\u003e [options]", + "usage": "azd ai project [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -8092,7 +8178,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai routine \u003ccommand\u003e [options]", + "usage": "azd ai routine [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -8363,12 +8449,12 @@ "versions": [ { "version": "0.1.0-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "metadata" ], - "usage": "azd ai skill \u003ccommand\u003e [options]", + "usage": "azd ai skill [options]", "examples": [ { "name": "list", @@ -8439,12 +8525,12 @@ }, { "version": "0.1.1-preview", - "requiredAzdVersion": "\u003e1.23.13", + "requiredAzdVersion": ">1.23.13", "capabilities": [ "custom-commands", "metadata" ], - "usage": "azd ai skill \u003ccommand\u003e [options]", + "usage": "azd ai skill [options]", "examples": [ { "name": "list", @@ -8699,7 +8785,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai toolbox \u003ccommand\u003e [options]", + "usage": "azd ai toolbox [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -8758,7 +8844,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai toolbox \u003ccommand\u003e [options]", + "usage": "azd ai toolbox [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -9090,4 +9176,4 @@ ] } ] -} \ No newline at end of file +} From 57d20cbac2f365717b46ad71bb018ad8291e7258 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Wed, 26 Aug 2026 14:09:11 +0530 Subject: [PATCH 17/24] feat(ai-agents): agent definition ref, harness gate, and manifest updates Rename agent manifest ref to agent definition ref, extend prompt harness gating, update agent YAML mapping and API models, and refresh init/show command behavior with tests. --- .../azure.ai.agents/internal/cmd/init.go | 10 +- .../cmd/init_from_templates_helpers.go | 30 ++-- .../internal/cmd/init_managed.go | 63 +++++-- .../cmd/init_managed_manifest_test.go | 44 ++++- .../azure.ai.agents/internal/cmd/show.go | 29 +++- .../azure.ai.agents/internal/cmd/show_test.go | 11 ++ .../internal/pkg/agents/agent_api/models.go | 64 +++++-- .../pkg/agents/agent_yaml/managed_test.go | 4 +- .../internal/pkg/agents/agent_yaml/map.go | 95 ++++++++--- .../pkg/agents/agent_yaml/prompt_features.go | 10 +- .../agents/agent_yaml/prompt_features_test.go | 8 +- .../agents/agent_yaml/prompt_harness_gate.go | 134 ++++++++++++++- .../agent_yaml/prompt_harness_gate_test.go | 141 ++++++++++++++-- .../internal/pkg/agents/agent_yaml/yaml.go | 97 ++++++++--- .../project/agent_definition_ref_test.go | 146 ++++++++++++++++ .../project/agent_manifest_ref_test.go | 159 ------------------ .../project/prompt_convention_test.go | 8 +- .../internal/project/prompt_graph.go | 2 +- .../internal/project/prompt_skills.go | 4 +- .../internal/project/prompt_skills_test.go | 17 +- .../internal/project/service_target_agent.go | 112 ++++++------ .../project/service_target_agent_test.go | 77 +++++++++ .../internal/project/service_target_prompt.go | 21 +++ 23 files changed, 935 insertions(+), 351 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_ref_test.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/agent_manifest_ref_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 9f697d7dd4c..ef4ea50b826 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1072,7 +1072,7 @@ azure.yaml is adopted as the project manifest and its referenced files are placed at the project root. When -m points at an agent manifest instead, the project's azure.yaml is generated from it. An agent manifest that declares kind: prompt scaffolds a prompt agent (or a managed agent when it also declares -harness: github-copilot), carrying over its model, instructions, skills, and tools. +a harness), carrying over its model, instructions, skills, and tools. The agent name written to agent.yaml is the Foundry agent identity. Foundry agents are unique by name within a project, so deploying with an existing name @@ -1239,10 +1239,10 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, return runInitManaged(ctx, flags, azdClient, harness, promptManifest) case promptManifest != nil: // No --kind: the manifest's own harness decides the flavor, so a - // `harness: github-copilot` template scaffolds a managed agent and a + // template with a `harness:` block scaffolds a managed agent and a // harness-less one a plain prompt agent. --harness still wins. harness, harnessErr := resolveManifestInitHarness( - flags.harness, promptManifest.definition.Harness, + flags.harness, promptManifest.definition.HarnessType(), ) if harnessErr != nil { return harnessErr @@ -1694,8 +1694,8 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "interactively. With --no-prompt, 'prompt' and 'managed' require --agent-name and "+ "either --model or --model-deployment (unless supplied by --manifest).") cmd.Flags().StringVar(&flags.harness, "harness", "", - "Execution harness for a prompt agent: 'github-copilot' (GitHub Copilot Brain+Hand) or 'none'. "+ - "Overrides the harness implied by --kind. Ignored for hosted agents.") + "Execution harness for a prompt agent: 'github-copilot' (GitHub Copilot Brain+Hand) "+ + "or 'none'. Overrides the harness implied by --kind. Ignored for hosted agents.") cmd.Flags().StringVar(&flags.infra, "infra", "", "Eject infrastructure-as-code from azure.yaml into ./infra/. "+ "A bare --infra ejects Bicep; --infra=terraform ejects Terraform and sets "+ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go index 1cea68f516c..4c63a31444b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go @@ -120,7 +120,8 @@ const ( // AgentKindChoiceManaged is the managed-agent path — a prompt agent that // additionally names an execution harness (GitHub Copilot), so Foundry // provisions a Brain+Hand sandbox for it. The scaffolded agent.yaml still - // uses kind: prompt; the only difference is `harness: github-copilot`. + // uses kind: prompt; the only difference is a `harness:` block naming the + // harness type. AgentKindChoiceManaged agentKindChoice = "managed" ) @@ -140,8 +141,8 @@ const harnessNone = "none" // resolveInitHarness resolves the harness written to the scaffolded agent.yaml. // An explicit --harness value always wins over the harness implied by the kind -// choice, so `--kind prompt --harness github-copilot` and `--kind managed` are -// equivalent. +// choice, so `--kind prompt --harness github-copilot` and +// `--kind managed` are equivalent. func resolveInitHarness(harnessFlag string, choice agentKindChoice) (string, error) { harness := strings.ToLower(strings.TrimSpace(harnessFlag)) switch harness { @@ -151,24 +152,23 @@ func resolveInitHarness(harnessFlag string, choice agentKindChoice) (string, err return "", nil case agent_api.ManagedAgentHarnessGitHubCopilot: return agent_api.ManagedAgentHarnessGitHubCopilot, nil - case agent_api.ManagedAgentHarnessGitHubCopilotRemoved: + } + + if replacement, removed := agent_api.RemovedManagedAgentHarnesses[harness]; removed { // Named separately from the generic "unknown value" case so the error // tells the user what to type instead of only what is allowed. return "", exterrors.Validation( exterrors.CodeInvalidParameter, - fmt.Sprintf( - "--harness %q is no longer accepted", - agent_api.ManagedAgentHarnessGitHubCopilotRemoved, - ), - fmt.Sprintf("use --harness %s instead", agent_api.ManagedAgentHarnessGitHubCopilot), - ) - default: - return "", exterrors.Validation( - exterrors.CodeInvalidParameter, - fmt.Sprintf("unknown --harness value %q", harnessFlag), - fmt.Sprintf("supported values are: %s, %s", agent_api.ManagedAgentHarnessGitHubCopilot, harnessNone), + fmt.Sprintf("--harness %q is no longer accepted", harness), + fmt.Sprintf("use --harness %s instead", replacement), ) } + + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("unknown --harness value %q", harnessFlag), + fmt.Sprintf("supported values are: %s, %s", agent_api.ManagedAgentHarnessGitHubCopilot, harnessNone), + ) } // promptAgentKind asks the user which agent kind to initialize. In no-prompt diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go index 5c0dfd59cae..57ccbc7d5bb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go @@ -24,12 +24,17 @@ import ( "google.golang.org/protobuf/types/known/structpb" ) -// promptAgentManifestFileName is the manifest filename `init` scaffolds. It is -// also written into azure.yaml as the service's `manifest:` value, so the link -// between the service and its manifest is visible in the project file rather -// than implied by a filename azd happens to look for. +// promptAgentManifestFileName is the agent definition filename `init` +// scaffolds. It is also referenced from azure.yaml through the service's `$ref` +// include, so the link between the service and its definition is visible in the +// project file rather than implied by a filename azd happens to look for. const promptAgentManifestFileName = "agent.yaml" +// promptAgentManifestRef is the `$ref` value written into azure.yaml. `$ref` +// paths resolve against the directory holding azure.yaml, and the explicit +// leading "./" marks it as a relative path rather than a bare name. +const promptAgentManifestRef = "./" + promptAgentManifestFileName + // promptAgentManifest is a prompt-agent definition supplied through // `--manifest` (or a positional template pointer), pre-loaded so runInitManaged // can seed the scaffold from it instead of prompting for each field. @@ -155,9 +160,9 @@ func loadPromptManifestFromPointer( // resolveManifestInitHarness resolves the harness for a prompt-agent manifest // adopted without an explicit --kind. An explicit --harness always wins; -// otherwise the manifest's own harness is honored, so a template that declares -// `harness: github-copilot` scaffolds a managed agent and one that declares none -// scaffolds a plain prompt agent. +// otherwise the manifest's own harness type is honored, so a template that +// declares one scaffolds a managed agent and one that declares none scaffolds a +// plain prompt agent. func resolveManifestInitHarness(harnessFlag, manifestHarness string) (string, error) { if strings.TrimSpace(harnessFlag) != "" { return resolveInitHarness(harnessFlag, AgentKindChoicePrompt) @@ -314,9 +319,9 @@ func runInitManaged( Kind: agent_yaml.AgentKindPrompt, }, Model: model, - // An empty harness is omitted from agent.yaml entirely, which is what + // A nil harness is omitted from agent.yaml entirely, which is what // distinguishes a plain prompt agent from a managed (harnessed) one. - Harness: harness, + Harness: promptScaffoldHarness(harness, manifest), // Instructions are inline, matching the prompt-agent API schema. Instructions: promptScaffoldInstructions(instructions), } @@ -324,6 +329,12 @@ func runInitManaged( // Tools, skills, connections, and the toolbox reference are the reason a // user supplies a template at all; dropping them would silently produce a // bare agent that does not match the template they asked for. + // + // displayName and metadata come along for the same reason: a hosted agent's + // azure.yaml carries description and metadata.tags straight from its + // template, and both reach the same CreateAgentRequest fields for a prompt + // agent, so a prompt agent scaffolded from a template should not silently + // lose the catalog labels the template author wrote. if manifest != nil { promptAgent.Skills = manifest.definition.Skills promptAgent.Tools = manifest.definition.Tools @@ -333,6 +344,8 @@ func runInitManaged( promptAgent.Connections = manifest.definition.Connections promptAgent.Toolbox = manifest.definition.Toolbox promptAgent.Memory = manifest.definition.Memory + promptAgent.AgentDefinition.DisplayName = manifest.definition.DisplayName + promptAgent.AgentDefinition.Metadata = manifest.definition.Metadata } if strings.TrimSpace(description) != "" { desc := strings.TrimSpace(description) @@ -421,12 +434,13 @@ func addPromptAgentService( return fmt.Errorf("marshaling prompt agent service config: %w", err) } - // Name the manifest explicitly on the service entry. Deploy would find - // agent.yaml by convention anyway, but writing it makes the service -> manifest - // edge readable in azure.yaml and gives the developer one line to edit when - // they want a different filename. + // Reference the definition file explicitly on the service entry. Deploy would + // find agent.yaml by convention anyway, but the `$ref` include makes the + // service -> definition edge readable in azure.yaml, gives the developer one + // line to edit when they want a different filename, and is the same directive + // every other Foundry resource uses to live in its own file. serviceProps, err := structpb.NewStruct(map[string]any{ - project.AgentManifestServiceKey: promptAgentManifestFileName, + project.AgentDefinitionRefKey: promptAgentManifestRef, }) if err != nil { return fmt.Errorf("marshaling prompt agent service properties: %w", err) @@ -738,6 +752,27 @@ func promptScaffoldInstructions(instructions string) string { return "You are a helpful AI assistant." } +// promptScaffoldHarness builds the `harness:` block for a scaffolded agent.yaml, +// or nil for a plain prompt agent so the key is omitted entirely. +// +// harnessType is already resolved from --harness and --kind, so it wins over the +// manifest's own type. The manifest's remaining harness configuration — pinned +// skills, sandbox sizing, built-in capability filter — is carried through, since +// dropping it would scaffold an agent that does not match the template the user +// asked for. +func promptScaffoldHarness(harnessType string, manifest *promptAgentManifest) *agent_yaml.PromptHarness { + harness := agent_yaml.NewPromptHarness(harnessType) + if harness == nil { + return nil + } + if manifest != nil && manifest.definition.Harness != nil { + harness.Skills = manifest.definition.Harness.Skills + harness.Environment = manifest.definition.Harness.Environment + harness.BuiltinTools = manifest.definition.Harness.BuiltinTools + } + return harness +} + // scaffoldPromptConventionFolders writes the convention-based authoring layout // next to agent.yaml so the deploy engine's folder conventions are discoverable // from a fresh init: diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go index 38aa8f53cb1..e9bcba803b0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go @@ -4,6 +4,7 @@ package cmd import ( + "reflect" "testing" "azureaiagent/internal/pkg/agents/agent_api" @@ -23,7 +24,7 @@ func TestLooksLikePromptAgentManifest(t *testing.T) { }, { name: "prompt agent with harness", - content: "kind: prompt\nname: my-agent\nmodel: gpt-4.1-mini\nharness: github-copilot\n", + content: "kind: prompt\nname: my-agent\nmodel: gpt-4.1-mini\nharness:\n type: github-copilot\n", want: true, }, { @@ -63,8 +64,10 @@ func TestLoadPromptAgentManifest(t *testing.T) { "name: triage-agent\n" + "description: Triages incoming issues\n" + "model: gpt-4.1\n" + - "harness: github-copilot\n" + + "harness:\n type: github-copilot\n skills:\n - name: summarize\n version: \"2\"\n" + "instructions: You triage issues.\n" + + "displayName: Triage Agent\n" + + "metadata:\n tags:\n - Prompt Agent\n" + "skills:\n - summarize\n" + "tools:\n - type: code_interpreter\n", ) @@ -85,12 +88,28 @@ func TestLoadPromptAgentManifest(t *testing.T) { if got := manifest.instructions(); got != "You triage issues." { t.Errorf("instructions = %q", got) } - if got := manifest.definition.Harness; got != agent_api.ManagedAgentHarnessGitHubCopilot { + if got := manifest.definition.HarnessType(); got != agent_api.ManagedAgentHarnessGitHubCopilot { t.Errorf("harness = %q", got) } + wantHarnessSkills := []agent_yaml.HarnessSkillRef{{Name: "summarize", Version: "2"}} + if got := manifest.definition.Harness.Skills; !reflect.DeepEqual(got, wantHarnessSkills) { + t.Errorf("harness skills = %+v, want %+v", got, wantHarnessSkills) + } if len(manifest.definition.Skills) != 1 || len(manifest.definition.Tools) != 1 { t.Errorf("skills/tools were not carried through: %+v", manifest.definition) } + // displayName and metadata are the catalog labels a hosted agent carries in + // azure.yaml. They reach the same CreateAgentRequest fields for a prompt + // agent, so the scaffold must not drop them. + if manifest.definition.DisplayName == nil || *manifest.definition.DisplayName != "Triage Agent" { + t.Errorf("displayName was not carried through: %+v", manifest.definition.DisplayName) + } + if manifest.definition.Metadata == nil { + t.Fatal("metadata was not carried through") + } + if got := (*manifest.definition.Metadata)["tags"]; !reflect.DeepEqual(got, []any{"Prompt Agent"}) { + t.Errorf("metadata tags = %+v", got) + } } func TestLoadPromptAgentManifest_RejectsNonPromptKind(t *testing.T) { @@ -133,10 +152,23 @@ func TestResolveManifestInitHarness(t *testing.T) { want string wantErr bool }{ - {name: "manifest harness is honored", manifestHarness: "github-copilot", want: "github-copilot"}, + { + name: "manifest harness is honored", + manifestHarness: "github-copilot", + want: "github-copilot", + }, {name: "no harness anywhere means plain prompt agent"}, - {name: "harness flag wins", harnessFlag: "github-copilot", manifestHarness: "", want: "github-copilot"}, - {name: "harness none overrides manifest", harnessFlag: "none", manifestHarness: "github-copilot", want: ""}, + { + name: "harness flag wins", + harnessFlag: "github-copilot", + want: "github-copilot", + }, + { + name: "harness none overrides manifest", + harnessFlag: "none", + manifestHarness: "github-copilot", + want: "", + }, {name: "unknown flag value", harnessFlag: "bogus", wantErr: true}, {name: "unknown manifest value", manifestHarness: "bogus", wantErr: true}, } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go index 8dcce19e84b..064ac57a750 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go @@ -255,10 +255,10 @@ func printPromptShowTable(agent *agent_api.AgentObject, settings *projectpkg.Pro def := promptDefinitionMap(latest) // Harness is the execution harness the platform runs the agent on, taken - // from the deployed definition's `harness` field (e.g. "github-copilot"). - // The previous implementation printed settings.BaseURL here, which is the - // harness *API base URL*, not the harness itself. - if harness := stringFromMap(def, "harness"); harness != "" { + // from the deployed definition's `harness` block. The previous + // implementation printed settings.BaseURL here, which is the harness *API + // base URL*, not the harness itself. + if harness := harnessTypeFromMap(def); harness != "" { fmt.Fprintf(w, "Harness:\t%s\n", displayHarness(harness)) } @@ -297,6 +297,27 @@ func stringFromMap(m map[string]any, key string) string { return "" } +// harnessTypeFromMap returns the harness discriminator from a deployed +// definition. +// +// Both shapes are handled because the field changed: agents created by earlier +// versions of azd carry a bare string, current ones carry an object with a +// `type`. Reading only one shape would blank the Harness row for half the +// agents in a project. +func harnessTypeFromMap(def map[string]any) string { + if def == nil { + return "" + } + switch harness := def["harness"].(type) { + case string: + return strings.TrimSpace(harness) + case map[string]any: + return stringFromMap(harness, "type") + default: + return "" + } +} + // displayHarness maps a harness identifier to a friendlier label, preserving // the raw identifier in parentheses for unambiguous reference. func displayHarness(harness string) string { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go index e1e7552cebd..367f77b884b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go @@ -406,6 +406,17 @@ func TestDisplayHarness(t *testing.T) { assert.Equal(t, "custom-harness", displayHarness("custom-harness")) } +// TestHarnessTypeFromMap covers both shapes `show` can be handed: agents +// created before the harness became a block still carry a bare string. +func TestHarnessTypeFromMap(t *testing.T) { + assert.Equal(t, "github-copilot", harnessTypeFromMap(map[string]any{ + "harness": map[string]any{"type": "github-copilot"}, + })) + assert.Equal(t, "ghcp", harnessTypeFromMap(map[string]any{"harness": "ghcp"})) + assert.Equal(t, "", harnessTypeFromMap(map[string]any{"harness": map[string]any{}})) + assert.Equal(t, "", harnessTypeFromMap(nil)) +} + func TestPromptDefinitionMap(t *testing.T) { version := agent_api.AgentVersionObject{ Definition: map[string]any{"harness": "github-copilot"}, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index e034a9de697..bce1b375508 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -304,16 +304,24 @@ type ManagedEnvironment struct { EnvironmentVariables map[string]string `json:"environment_variables,omitempty"` } -// ManagedAgentHarnessGitHubCopilot is the execution harness identifier sent in -// the managed agent definition's `harness` field to run the agent on the -// GitHub Copilot harness. +// ManagedAgentHarnessGitHubCopilot is the discriminator sent in the managed +// agent definition's `harness.type` field to run the agent on the GitHub +// Copilot harness. +// +// The managed-agent spec writes the wire discriminator as +// "github_copilot_preview". azd deliberately keeps the preview-free spelling +// until the service confirms the versioned one, so that a manifest does not +// have to be rewritten twice. const ManagedAgentHarnessGitHubCopilot = "github-copilot" -// ManagedAgentHarnessGitHubCopilotRemoved is the abbreviated spelling this -// harness used previously. It is retained only so validation can name the -// replacement when it encounters an old manifest; it is never sent on the wire -// and never accepted as input. -const ManagedAgentHarnessGitHubCopilotRemoved = "ghcp" +// RemovedManagedAgentHarnesses maps a harness spelling the service no longer +// accepts to the spelling that replaced it. +// +// These are retained only so validation can name the replacement when it meets +// an old manifest; none of them is ever sent on the wire or accepted as input. +var RemovedManagedAgentHarnesses = map[string]string{ + "ghcp": ManagedAgentHarnessGitHubCopilot, +} // HarnessSkillReference pins one published Foundry skill onto a harnessed // agent's definition. @@ -338,8 +346,41 @@ type HarnessSkillReference struct { // service accepts that field but never resolves it, so a name written there is // silently inert (including a name that matches no skill at all). type ManagedAgentHarness struct { - Type string `json:"type"` - Skills []HarnessSkillReference `json:"skills,omitempty"` + Type string `json:"type"` + Skills []HarnessSkillReference `json:"skills,omitempty"` + Environment *HarnessEnvironment `json:"environment,omitempty"` + BuiltinTools *HarnessBuiltInTools `json:"builtin_tools,omitempty"` +} + +// HarnessEnvironment sizes the sandbox the harness runs the agent in. +// +// This is deliberately far narrower than ManagedEnvironment: the harness owns +// its own image, packages, and startup, so the only knobs a customer gets are +// how much compute the sandbox is given and how long it survives idle. +// +// Every field is a pointer so an unset knob leaves the service default in place +// rather than pinning it to a zero value. +type HarnessEnvironment struct { + // CPU and Memory size the sandbox (e.g. "1" and "2Gi"). The service treats + // them as a pair and rejects one without the other. + CPU *string `json:"cpu,omitempty"` + Memory *string `json:"memory,omitempty"` + + // IdleTimeoutSeconds is how long an idle sandbox is kept warm before it is + // reclaimed. + IdleTimeoutSeconds *int `json:"idle_timeout_seconds,omitempty"` +} + +// HarnessBuiltInTools narrows the capabilities the harness exposes to the agent +// out of the box. +// +// The effective set is (Allowed, defaulting to every capability) minus Excluded. +// Both fields are pointers to slices so an explicitly empty `allowed: []`, +// which turns every built-in capability off, stays distinguishable from an +// omitted `allowed`, which leaves them all on. +type HarnessBuiltInTools struct { + Allowed *[]string `json:"allowed,omitempty"` + Excluded *[]string `json:"excluded,omitempty"` } // UnmarshalJSON accepts either the object form or the bare string form the @@ -348,8 +389,7 @@ type ManagedAgentHarness struct { func (h *ManagedAgentHarness) UnmarshalJSON(data []byte) error { var asString string if err := json.Unmarshal(data, &asString); err == nil { - h.Type = asString - h.Skills = nil + *h = ManagedAgentHarness{Type: asString} return nil } type harnessAlias ManagedAgentHarness diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go index e7cda264d40..cb0f3b081aa 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go @@ -182,7 +182,7 @@ func TestCreatePromptAgentAPIRequest_Harness(t *testing.T) { Name: "my-agent", }, Model: "gpt-4.1-mini", - Harness: tc.harness, + Harness: NewPromptHarness(tc.harness), Instructions: "Be helpful.", } @@ -226,7 +226,7 @@ func TestCreatePromptAgentAPIRequest_HarnessSkills(t *testing.T) { AgentDefinition: AgentDefinition{Kind: AgentKindPrompt, Name: "my-agent"}, Model: "gpt-4.1-mini", Instructions: "Be helpful.", - Harness: agent_api.ManagedAgentHarnessGitHubCopilot, + Harness: NewPromptHarness(agent_api.ManagedAgentHarnessGitHubCopilot), Skills: []string{"duplicate-check"}, HarnessSkills: []HarnessSkillRef{ {Name: "duplicate-check", Version: "3"}, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index 55b72a0ca1e..54757048738 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -7,6 +7,7 @@ import ( "fmt" "maps" "math" + "slices" "strings" "azureaiagent/internal/pkg/agents/agent_api" @@ -469,50 +470,101 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB // published from the skills/ folder, and are matched by name so a manifest entry // naming a folder-published skill does not produce a duplicate reference. func mapHarness(promptAgent PromptAgent) *agent_api.ManagedAgentHarness { - harnessType := strings.TrimSpace(promptAgent.Harness) + harnessType := promptAgent.HarnessType() if harnessType == "" { return nil } - harness := &agent_api.ManagedAgentHarness{Type: harnessType} + harness := &agent_api.ManagedAgentHarness{ + Type: harnessType, + Environment: mapHarnessEnvironment(promptAgent.Harness.Environment), + BuiltinTools: mapHarnessBuiltInTools(promptAgent.Harness.BuiltinTools), + } + seen := make(map[string]struct{}, len(promptAgent.HarnessSkills)) - for _, skill := range promptAgent.HarnessSkills { - name := strings.TrimSpace(skill.Name) + addSkill := func(name, version string) { + name = strings.TrimSpace(name) if name == "" { - continue + return + } + if _, dup := seen[name]; dup { + return } seen[name] = struct{}{} harness.Skills = append(harness.Skills, agent_api.HarnessSkillReference{ Name: name, - Version: strings.TrimSpace(skill.Version), + Version: strings.TrimSpace(version), }) } + + // Graph-published skills go first: they are the only ones azd knows a version + // for, and the dedupe below keeps a hand-written reference to the same name + // from replacing a pinned version with an unpinned one. + for _, skill := range promptAgent.HarnessSkills { + addSkill(skill.Name, skill.Version) + } + for _, skill := range promptAgent.Harness.Skills { + addSkill(skill.Name, skill.Version) + } for _, name := range promptAgent.Skills { - name = strings.TrimSpace(name) - if name == "" { - continue - } - if _, dup := seen[name]; dup { - continue - } - seen[name] = struct{}{} - // No version: this name came from the manifest, not from a publish, so - // azd has nothing to pin it to and defers to the service's default. - harness.Skills = append(harness.Skills, agent_api.HarnessSkillReference{Name: name}) + // No version: this name came from the definition-level `skills` field, not + // from a publish, so azd has nothing to pin it to and defers to the + // service's default. + addSkill(name, "") } return harness } +// mapHarnessEnvironment converts the authored sandbox sizing to its API shape. +// Empty strings become nil so an omitted knob leaves the service default in +// place rather than pinning it to "". +func mapHarnessEnvironment(env *PromptHarnessEnvironment) *agent_api.HarnessEnvironment { + if env == nil { + return nil + } + mapped := &agent_api.HarnessEnvironment{IdleTimeoutSeconds: env.IdleTimeoutSeconds} + if cpu := strings.TrimSpace(env.Cpu); cpu != "" { + mapped.CPU = new(cpu) + } + if memory := strings.TrimSpace(env.Memory); memory != "" { + mapped.Memory = new(memory) + } + if mapped.CPU == nil && mapped.Memory == nil && mapped.IdleTimeoutSeconds == nil { + return nil + } + return mapped +} + +// mapHarnessBuiltInTools converts the authored built-in capability filter to its +// API shape, preserving the distinction between an explicitly empty list (turn +// every capability off) and an omitted one (leave them all on). +func mapHarnessBuiltInTools(builtin *PromptHarnessBuiltInTools) *agent_api.HarnessBuiltInTools { + if builtin == nil { + return nil + } + if builtin.Allowed == nil && builtin.Excluded == nil { + return nil + } + mapped := &agent_api.HarnessBuiltInTools{} + if builtin.Allowed != nil { + mapped.Allowed = new(slices.Clone(*builtin.Allowed)) + } + if builtin.Excluded != nil { + mapped.Excluded = new(slices.Clone(*builtin.Excluded)) + } + return mapped +} + // API CreateAgentRequest expected by the Foundry prompt-agent endpoint. // // Prompt agents are simpler than hosted agents — the customer only declares // model + instructions (plus optional skills/policies), so no image/cpu/memory // fields are required from the customer for the minimum case. // -// The agent's Harness is omitted entirely when empty: a harness-less prompt +// The agent's Harness is omitted entirely when nil: a harness-less prompt // agent is run directly by Foundry, while a managed agent names its harness -// (e.g. "github-copilot") and the platform provisions a Brain+Hand sandbox for -// it. +// (e.g. "github-copilot") and the platform provisions a Brain+Hand +// sandbox for it. func CreatePromptAgentAPIRequest( promptAgent PromptAgent, buildConfig *AgentBuildConfig, @@ -526,6 +578,9 @@ func CreatePromptAgentAPIRequest( if err := promptAgent.ValidateHarness(); err != nil { return nil, err } + if err := promptAgent.ValidateHarnessBlock(); err != nil { + return nil, err + } if err := promptAgent.ValidateHarnessFeatures(); err != nil { return nil, err } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go index 485629d4c1f..8efa7d28551 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go @@ -120,7 +120,7 @@ func (p PromptAgent) declares(feature PromptFeature) bool { // its harness cannot honor, in a stable order. It returns nil for a harness-less // prompt agent, which supports all of them. func (p PromptAgent) UnsupportedHarnessFeatures() []PromptFeature { - if strings.TrimSpace(p.Harness) == "" { + if !p.harnessed() { return nil } @@ -153,7 +153,7 @@ func (p PromptAgent) ValidateHarnessFeatures() error { return fmt.Errorf( "agent.yaml configures %s, which the %q harness does not support yet", - strings.Join(names, ", "), strings.TrimSpace(p.Harness), + strings.Join(names, ", "), p.HarnessType(), ) } @@ -207,16 +207,14 @@ func (p PromptAgent) ValidatePolicies() error { // has never heard of may simply be newer than this build, and hard-failing // would make every new Foundry harness a breaking change in azd. Only spellings // known to be wrong are refused, and each one names its replacement. -var removedHarnesses = map[string]string{ - agent_api.ManagedAgentHarnessGitHubCopilotRemoved: agent_api.ManagedAgentHarnessGitHubCopilot, -} +var removedHarnesses = agent_api.RemovedManagedAgentHarnesses // ValidateHarness rejects harness spellings that have been replaced. The value // is passed to the service verbatim, and the service ignores a harness it does // not recognize rather than erroring — so an outdated spelling would otherwise // publish a plain prompt agent while the manifest claims a managed one. func (p PromptAgent) ValidateHarness() error { - harness := strings.TrimSpace(p.Harness) + harness := p.HarnessType() if harness == "" { return nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go index 23ca172b2cf..32d97b10d30 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go @@ -150,7 +150,7 @@ func TestPromptAgent_ValidateHarness(t *testing.T) { {name: "whitespace is treated as absent", harness: " "}, {name: "current spelling is accepted", harness: "github-copilot"}, { - name: "removed spelling names its replacement", + name: "abbreviated spelling names its replacement", harness: "ghcp", wantErrPart: "github-copilot", }, @@ -164,7 +164,7 @@ func TestPromptAgent_ValidateHarness(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := PromptAgent{Harness: tc.harness}.ValidateHarness() + err := PromptAgent{Harness: NewPromptHarness(tc.harness)}.ValidateHarness() if tc.wantErrPart == "" { require.NoError(t, err) return @@ -238,7 +238,7 @@ func TestValidateHarnessFeatures(t *testing.T) { t.Parallel() agent := tc.agent - agent.Harness = tc.harness + agent.Harness = NewPromptHarness(tc.harness) if len(tc.wantRejected) == 0 { require.NoError(t, agent.ValidateHarnessFeatures()) @@ -275,7 +275,7 @@ func TestUnsupportedHarnessFeatures_ReportingOrder(t *testing.T) { // A harness-less agent is never gated, whatever the switch says. require.NoError(t, agent.ValidateHarnessFeatures()) - agent.Harness = "github-copilot" + agent.Harness = NewPromptHarness("github-copilot") err := agent.ValidateHarnessFeatures() require.Error(t, err) require.Contains(t, err.Error(), "github-copilot") diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate.go index ae1e22c7d65..eb15e1bcf94 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate.go @@ -8,6 +8,8 @@ import ( "slices" "sort" "strings" + + "azureaiagent/internal/pkg/agents/agent_api" ) // The gates in this file apply *only* to harnessed prompt agents. A harness-less @@ -49,9 +51,133 @@ var harnessRejectedToolTypes = map[string]struct{}{ // reasoningEffortKey is the single `reasoning` property a harness honors. const reasoningEffortKey = "effort" +// harnessBuiltInCapabilities are the built-in capability groups a harnessed +// agent may allow or exclude, in the order they are reported. +// +// Unlike tool types, this list *is* closed: `builtin_tools` filters a fixed set +// the harness provides, so a name outside it can only be a typo, and silently +// dropping it would leave the author believing they had turned a capability off. +var harnessBuiltInCapabilities = []string{ + "filesystem_read", + "filesystem_write", + "shell", + "subagents", + "web", +} + +// NewPromptHarness returns a harness block naming only its type, which is the +// whole of the block for an agent that takes the harness defaults. It returns +// nil for an empty type so callers can pass an unresolved harness straight +// through and get a plain prompt agent. +func NewPromptHarness(harnessType string) *PromptHarness { + harnessType = strings.TrimSpace(harnessType) + if harnessType == "" { + return nil + } + return &PromptHarness{Type: harnessType} +} + +// HarnessType returns the harness discriminator the agent runs on, or "" for a +// plain prompt agent with no harness. +func (p PromptAgent) HarnessType() string { + if p.Harness == nil { + return "" + } + return strings.TrimSpace(p.Harness.Type) +} + // harnessed reports whether the agent names an execution harness. func (p PromptAgent) harnessed() bool { - return strings.TrimSpace(p.Harness) != "" + return p.HarnessType() != "" +} + +// ValidateHarnessBlock rejects a malformed `harness:` block. +// +// Each rule mirrors one the service enforces, so failing here turns an opaque +// API rejection into a message that names the offending key. +func (p PromptAgent) ValidateHarnessBlock() error { + if p.Harness == nil { + return nil + } + if p.HarnessType() == "" { + return fmt.Errorf( + "agent.yaml declares a harness with no type; set harness.type (for example %q), "+ + "or remove the harness block to run as a plain prompt agent", + agent_api.ManagedAgentHarnessGitHubCopilot, + ) + } + if err := p.validateHarnessEnvironment(); err != nil { + return err + } + return p.validateHarnessBuiltInTools() +} + +// validateHarnessEnvironment rejects a half-specified sandbox size. +// +// cpu and memory are a pair: the service refuses one without the other rather +// than defaulting the missing half, so a manifest setting only `cpu` would fail +// at deploy with no indication that `memory` is what is missing. +func (p PromptAgent) validateHarnessEnvironment() error { + env := p.Harness.Environment + if env == nil { + return nil + } + cpu := strings.TrimSpace(env.Cpu) + memory := strings.TrimSpace(env.Memory) + if (cpu == "") == (memory == "") { + return nil + } + + set, missing := "cpu", "memory" + if cpu == "" { + set, missing = "memory", "cpu" + } + return fmt.Errorf( + "agent.yaml sets harness.environment.%s without harness.environment.%s; "+ + "the %q harness sizes the sandbox from both, so set them together or set neither", + set, missing, p.HarnessType(), + ) +} + +// validateHarnessBuiltInTools rejects capability names the harness does not +// define, in either the allowed or the excluded list. +func (p PromptAgent) validateHarnessBuiltInTools() error { + builtin := p.Harness.BuiltinTools + if builtin == nil { + return nil + } + + var unknown []string + check := func(field string, names *[]string) { + if names == nil { + return + } + for _, name := range *names { + name = strings.TrimSpace(name) + if slices.Contains(harnessBuiltInCapabilities, name) { + continue + } + entry := fmt.Sprintf("%s.%s", field, name) + if !slices.Contains(unknown, entry) { + unknown = append(unknown, entry) + } + } + } + check("allowed", builtin.Allowed) + check("excluded", builtin.Excluded) + + if len(unknown) == 0 { + return nil + } + sort.Strings(unknown) + + return fmt.Errorf( + "agent.yaml lists harness.builtin_tools.%s, which the %q harness does not define; "+ + "supported capabilities are %s", + strings.Join(unknown, ", harness.builtin_tools."), + p.HarnessType(), + strings.Join(harnessBuiltInCapabilities, ", "), + ) } // ValidateHarnessFields rejects sampling and output-shaping fields a harnessed @@ -83,7 +209,7 @@ func (p PromptAgent) ValidateHarnessFields() error { return fmt.Errorf( "agent.yaml sets %s, which the %q harness does not accept because it controls "+ "sampling and response format itself", - strings.Join(rejected, ", "), strings.TrimSpace(p.Harness), + strings.Join(rejected, ", "), p.HarnessType(), ) } @@ -115,7 +241,7 @@ func (p PromptAgent) validateHarnessReasoning() error { return fmt.Errorf( "agent.yaml sets reasoning.%s, which the %q harness does not accept; "+ "only reasoning.%s is supported", - strings.Join(extra, ", reasoning."), strings.TrimSpace(p.Harness), reasoningEffortKey, + strings.Join(extra, ", reasoning."), p.HarnessType(), reasoningEffortKey, ) } @@ -153,6 +279,6 @@ func (p PromptAgent) ValidateHarnessTools() error { return fmt.Errorf( "agent.yaml declares tool %s, which the %q harness does not accept because it runs "+ "tools through a platform-managed toolbox", - strings.Join(rejected, ", "), strings.TrimSpace(p.Harness), + strings.Join(rejected, ", "), p.HarnessType(), ) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate_test.go index d1e28bf7618..07ceed891bd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate_test.go @@ -7,8 +7,14 @@ import ( "testing" "github.com/stretchr/testify/require" + + "azureaiagent/internal/pkg/agents/agent_api" ) +// testHarness is the harness block the gate cases below attach. Only the type +// matters to these gates, so every case shares one value. +var testHarness = NewPromptHarness(agent_api.ManagedAgentHarnessGitHubCopilot) + // TestValidateHarnessFields covers the sampling and output-shaping fields a // harness controls itself. The harness-less cases matter most: they are the // guarantee that this gate never narrows what a plain prompt agent accepts. @@ -30,36 +36,36 @@ func TestValidateHarnessFields(t *testing.T) { }, { name: "harnessed agent with no sampling fields", - agent: PromptAgent{Harness: "github-copilot"}, + agent: PromptAgent{Harness: testHarness}, }, { name: "harnessed agent rejects temperature", - agent: PromptAgent{Harness: "github-copilot", Temperature: &temperature}, + agent: PromptAgent{Harness: testHarness, Temperature: &temperature}, wantErr: true, wantMessage: "temperature", }, { name: "harnessed agent rejects top_p", - agent: PromptAgent{Harness: "github-copilot", TopP: &topP}, + agent: PromptAgent{Harness: testHarness, TopP: &topP}, wantErr: true, wantMessage: "top_p", }, { name: "harnessed agent rejects tool_choice", - agent: PromptAgent{Harness: "github-copilot", ToolChoice: "auto"}, + agent: PromptAgent{Harness: testHarness, ToolChoice: "auto"}, wantErr: true, wantMessage: "tool_choice", }, { name: "harnessed agent rejects text", - agent: PromptAgent{Harness: "github-copilot", Text: map[string]any{"format": "json"}}, + agent: PromptAgent{Harness: testHarness, Text: map[string]any{"format": "json"}}, wantErr: true, wantMessage: "text", }, { name: "all rejected fields are reported together in a stable order", agent: PromptAgent{ - Harness: "github-copilot", + Harness: testHarness, Temperature: &temperature, TopP: &topP, ToolChoice: "auto", @@ -71,14 +77,14 @@ func TestValidateHarnessFields(t *testing.T) { { name: "harnessed agent accepts reasoning.effort", agent: PromptAgent{ - Harness: "github-copilot", + Harness: testHarness, Reasoning: map[string]any{"effort": "medium"}, }, }, { name: "harnessed agent rejects other reasoning properties", agent: PromptAgent{ - Harness: "github-copilot", + Harness: testHarness, Reasoning: map[string]any{"effort": "medium", "summary": "detailed"}, }, wantErr: true, @@ -87,7 +93,7 @@ func TestValidateHarnessFields(t *testing.T) { { name: "non-mapping reasoning is left to the schema check", agent: PromptAgent{ - Harness: "github-copilot", + Harness: testHarness, Reasoning: "medium", }, }, @@ -134,7 +140,7 @@ func TestValidateHarnessTools(t *testing.T) { { name: "harnessed agent accepts toolbox-backed tools", agent: PromptAgent{ - Harness: "github-copilot", + Harness: testHarness, Tools: []any{ map[string]any{"type": "code_interpreter"}, map[string]any{"type": "file_search"}, @@ -145,7 +151,7 @@ func TestValidateHarnessTools(t *testing.T) { { name: "harnessed agent rejects a function tool", agent: PromptAgent{ - Harness: "github-copilot", + Harness: testHarness, Tools: []any{map[string]any{"type": "function", "name": "get_order_status"}}, }, wantErr: true, @@ -154,7 +160,7 @@ func TestValidateHarnessTools(t *testing.T) { { name: "harnessed agent rejects bing_grounding", agent: PromptAgent{ - Harness: "github-copilot", + Harness: testHarness, Tools: []any{map[string]any{"type": "bing_grounding"}}, }, wantErr: true, @@ -163,7 +169,7 @@ func TestValidateHarnessTools(t *testing.T) { { name: "rejected tool types are deduplicated and sorted", agent: PromptAgent{ - Harness: "github-copilot", + Harness: testHarness, Tools: []any{ map[string]any{"type": "shell"}, map[string]any{"type": "function", "name": "a"}, @@ -176,14 +182,14 @@ func TestValidateHarnessTools(t *testing.T) { { name: "an unrecognized tool type still deploys", agent: PromptAgent{ - Harness: "github-copilot", + Harness: testHarness, Tools: []any{map[string]any{"type": "some_future_tool"}}, }, }, { name: "a malformed entry is left to ValidateTools", agent: PromptAgent{ - Harness: "github-copilot", + Harness: testHarness, Tools: []any{"not-a-mapping"}, }, }, @@ -203,3 +209,108 @@ func TestValidateHarnessTools(t *testing.T) { }) } } + +// TestValidateHarnessBlock covers the shape of the harness block itself: the +// rules the service enforces on `type`, `environment`, and `builtin_tools`. +func TestValidateHarnessBlock(t *testing.T) { + t.Parallel() + + idle := 900 + + cases := []struct { + name string + agent PromptAgent + wantErr bool + wantMessage string + }{ + { + name: "no harness block at all", + agent: PromptAgent{}, + }, + { + name: "type alone is a complete block", + agent: PromptAgent{Harness: testHarness}, + }, + { + name: "a block with no type is rejected", + agent: PromptAgent{Harness: &PromptHarness{}}, + wantErr: true, + wantMessage: "harness with no type", + }, + { + name: "cpu and memory together are accepted", + agent: PromptAgent{Harness: &PromptHarness{ + Type: agent_api.ManagedAgentHarnessGitHubCopilot, + Environment: &PromptHarnessEnvironment{Cpu: "1", Memory: "2Gi", IdleTimeoutSeconds: &idle}, + }}, + }, + { + name: "idle timeout alone is accepted", + agent: PromptAgent{Harness: &PromptHarness{ + Type: agent_api.ManagedAgentHarnessGitHubCopilot, + Environment: &PromptHarnessEnvironment{IdleTimeoutSeconds: &idle}, + }}, + }, + { + name: "cpu without memory is rejected", + agent: PromptAgent{Harness: &PromptHarness{ + Type: agent_api.ManagedAgentHarnessGitHubCopilot, + Environment: &PromptHarnessEnvironment{Cpu: "1"}, + }}, + wantErr: true, + wantMessage: "harness.environment.cpu without harness.environment.memory", + }, + { + name: "memory without cpu is rejected", + agent: PromptAgent{Harness: &PromptHarness{ + Type: agent_api.ManagedAgentHarnessGitHubCopilot, + Environment: &PromptHarnessEnvironment{Memory: "2Gi"}, + }}, + wantErr: true, + wantMessage: "harness.environment.memory without harness.environment.cpu", + }, + { + name: "known capabilities are accepted", + agent: PromptAgent{Harness: &PromptHarness{ + Type: agent_api.ManagedAgentHarnessGitHubCopilot, + BuiltinTools: &PromptHarnessBuiltInTools{ + Allowed: &[]string{"filesystem_read", "web", "subagents"}, + Excluded: &[]string{"shell"}, + }, + }}, + }, + { + name: "an empty allowed list turns everything off and is accepted", + agent: PromptAgent{Harness: &PromptHarness{ + Type: agent_api.ManagedAgentHarnessGitHubCopilot, + BuiltinTools: &PromptHarnessBuiltInTools{Allowed: &[]string{}}, + }}, + }, + { + name: "an unknown capability is rejected rather than dropped", + agent: PromptAgent{Harness: &PromptHarness{ + Type: agent_api.ManagedAgentHarnessGitHubCopilot, + BuiltinTools: &PromptHarnessBuiltInTools{ + Allowed: &[]string{"filesystem_reed"}, + Excluded: &[]string{"netwrok"}, + }, + }}, + wantErr: true, + wantMessage: "harness.builtin_tools.allowed.filesystem_reed", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.agent.ValidateHarnessBlock() + if !tc.wantErr { + require.NoError(t, err) + return + } + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantMessage) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index 6daf23b5ae2..167b4ed892b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -245,6 +245,74 @@ type ContainerAgent struct { Policies []Policy `json:"policies,omitempty" yaml:"policies,omitempty"` } +// HarnessSkillRef is a skill pinned onto a harnessed agent by name and, +// optionally, version. +// +// The deploy graph fills the version in from the publish it just performed, +// because the service rejects a reference that omits it. An author writing the +// reference by hand may leave it out and take the skill's current default. +type HarnessSkillRef struct { + Name string `json:"name" yaml:"name"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` +} + +// PromptHarness is the `harness:` block of a prompt agent's agent.yaml. +// +// It is an object rather than the bare harness name it used to be, because the +// harness owns configuration of its own: which skills are provisioned into its +// sandbox, how large that sandbox is, and which of its built-in capabilities the +// agent is allowed to reach. Only Type is required; a block that names nothing +// else is equivalent to the old `harness: ` string. +type PromptHarness struct { + // Type is the harness discriminator, e.g. + // agent_api.ManagedAgentHarnessGitHubCopilot ("github-copilot"). + // It is passed through verbatim: azd keeps no allowlist of harness names, so + // a harness the service gains later needs no change here. + Type string `json:"type" yaml:"type"` + + // Skills pins published Foundry skills into the harness sandbox. Skills live + // here rather than on the definition because a skill is instructions plus the + // scripts they reference, so it needs the sandbox to run at all — a + // harness-less prompt agent gets no skill execution. + Skills []HarnessSkillRef `json:"skills,omitempty" yaml:"skills,omitempty"` + + // Environment sizes the sandbox. Optional; the platform defaults it. + Environment *PromptHarnessEnvironment `json:"environment,omitempty" yaml:"environment,omitempty"` + + // BuiltinTools narrows the harness's built-in capabilities. Optional; every + // capability is available when it is omitted. + BuiltinTools *PromptHarnessBuiltInTools `json:"builtin_tools,omitempty" yaml:"builtin_tools,omitempty"` +} + +// PromptHarnessEnvironment sizes a harnessed agent's sandbox. +// +// The harness supplies its own image, packages, and startup commands, so unlike +// a hosted agent none of those are customer-configurable here. +type PromptHarnessEnvironment struct { + // Cpu and Memory are the sandbox's compute allocation (e.g. "1" and "2Gi"). + // The service treats them as a pair: setting one without the other is an + // error rather than a partial override. + Cpu string `json:"cpu,omitempty" yaml:"cpu,omitempty"` + Memory string `json:"memory,omitempty" yaml:"memory,omitempty"` + + // IdleTimeoutSeconds is how long an idle sandbox is kept warm. A pointer so + // an explicit 0 (reclaim immediately) is distinguishable from "not set", + // which leaves the service default in place. + IdleTimeoutSeconds *int `json:"idle_timeout_seconds,omitempty" yaml:"idle_timeout_seconds,omitempty"` +} + +// PromptHarnessBuiltInTools narrows the built-in capabilities the harness +// exposes to the agent. The effective set is (Allowed, defaulting to all) minus +// Excluded; see harnessBuiltInCapabilities for the recognized names. +// +// Both fields are pointers to slices so an explicit `allowed: []`, which turns +// every built-in capability off, stays distinguishable from an omitted +// `allowed`, which leaves them all on. +type PromptHarnessBuiltInTools struct { + Allowed *[]string `json:"allowed,omitempty" yaml:"allowed,omitempty"` + Excluded *[]string `json:"excluded,omitempty" yaml:"excluded,omitempty"` +} + // PromptAgent represents a Foundry "prompt" agent — a PES (Prompt Execution // Service) backed agent. The customer declares the model and instructions; the // platform manages the runtime, lifecycle, and orchestration. @@ -253,20 +321,10 @@ type ContainerAgent struct { // code; the only required fields are ModelDeploymentName and Instructions. // // The optional Harness field selects between the two prompt-agent flavors: -// - Harness empty — a plain prompt agent. Foundry runs model + instructions -// - tools directly; there is no sandbox to provision. -// - Harness set (e.g. "github-copilot") — a managed agent whose Brain+Hand -// sandbox is provisioned by the platform on demand and driven by the named -// harness. -// -// HarnessSkillRef is a published skill pinned onto a harnessed agent, resolved -// to the version that was actually uploaded. The version is carried explicitly -// because the service rejects a skill reference that omits it. -type HarnessSkillRef struct { - Name string - Version string -} - +// - Harness nil — a plain prompt agent. Foundry runs model + instructions + +// tools directly; there is no sandbox to provision. +// - Harness set — a managed agent whose Brain+Hand sandbox is provisioned by +// the platform on demand and driven by the named harness. type PromptAgent struct { AgentDefinition `json:",inline" yaml:",inline"` @@ -279,11 +337,10 @@ type PromptAgent struct { // Foundry prompt-agent API expects on the wire. Model string `json:"model" yaml:"model"` - // Harness names the execution harness the platform runs the agent on, for - // example agent_api.ManagedAgentHarnessGitHubCopilot ("github-copilot"). - // Leave it empty for a plain prompt agent with no harness; the field is then - // omitted from the create request entirely. - Harness string `json:"harness,omitempty" yaml:"harness,omitempty"` + // Harness selects and configures the execution harness the platform runs + // the agent on. Leave it nil for a plain prompt agent with no harness; the + // field is then omitted from the create request entirely. + Harness *PromptHarness `json:"harness,omitempty" yaml:"harness,omitempty"` // Instructions is the system/developer message inserted into the model's // context. It is declared inline, matching the prompt-agent API schema. @@ -295,7 +352,7 @@ type PromptAgent struct { // HarnessSkills carries the skills a harnessed agent runs, resolved to the // exact versions that were published. It is populated by the deploy graph // from the agent's skills/ folder, never authored, and is therefore excluded - // from both YAML and JSON. + // from both YAML and JSON — an author pins skills through Harness.Skills. // // It exists separately from Skills because the two land in different places // on the wire: a harnessed agent's skills nest under `harness`, where the diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_ref_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_ref_test.go new file mode 100644 index 00000000000..308b143db66 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_ref_test.go @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func mustStruct(t *testing.T, fields map[string]any) *structpb.Struct { + t.Helper() + + s, err := structpb.NewStruct(fields) + require.NoError(t, err) + return s +} + +// TestDeclaredAgentDefinitionRef covers where the `$ref:` include may live on a +// service entry. Service-level properties win over the nested config block so +// the unified azure.yaml shape reads the same way the inline agent definition +// does. +func TestDeclaredAgentDefinitionRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + svc *azdext.ServiceConfig + want string + }{ + { + name: "nil service", + }, + { + name: "no ref declared falls back to the convention", + svc: &azdext.ServiceConfig{Name: "agent"}, + }, + { + name: "service-level ref", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"$ref": "./agent.yaml"}), + }, + want: "./agent.yaml", + }, + { + name: "config-level ref", + svc: &azdext.ServiceConfig{ + Config: mustStruct(t, map[string]any{"$ref": "./nested.yaml"}), + }, + want: "./nested.yaml", + }, + { + name: "service-level wins over config-level", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"$ref": "./outer.yaml"}), + Config: mustStruct(t, map[string]any{"$ref": "./inner.yaml"}), + }, + want: "./outer.yaml", + }, + { + name: "blank value is treated as undeclared", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"$ref": " "}), + }, + }, + { + name: "non-string value is ignored", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"$ref": 42}), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tc.want, declaredAgentDefinitionRef(tc.svc)) + }) + } +} + +// TestResolveDeclaredRefPath pins the confinement rules. A `$ref` resolves +// against the directory holding azure.yaml — the same anchor the shared include +// machinery uses — and may not escape it. +func TestResolveDeclaredRefPath(t *testing.T) { + t.Parallel() + + root := t.TempDir() + + tests := []struct { + name string + declared string + wantRel string + wantErr bool + }{ + { + name: "sibling file", + declared: "./agent.yaml", + wantRel: "agent.yaml", + }, + { + name: "bare name", + declared: "agent.yaml", + wantRel: "agent.yaml", + }, + { + name: "nested file", + declared: "./src/triage/agent.yml", + wantRel: filepath.Join("src", "triage", "agent.yml"), + }, + { + name: "escaping the project root is rejected", + declared: "../agent.yaml", + wantErr: true, + }, + { + name: "absolute paths are rejected", + declared: "/etc/agent.yaml", + wantErr: true, + }, + { + name: "non-YAML extensions are rejected", + declared: "./agent.json", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := resolveDeclaredRefPath(root, tc.declared, "triage-agent") + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, filepath.Join(root, tc.wantRel), got) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_manifest_ref_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_manifest_ref_test.go deleted file mode 100644 index d677bb6f1b7..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_manifest_ref_test.go +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package project - -import ( - "path/filepath" - "testing" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/types/known/structpb" -) - -func mustStruct(t *testing.T, fields map[string]any) *structpb.Struct { - t.Helper() - - s, err := structpb.NewStruct(fields) - require.NoError(t, err) - return s -} - -// TestDeclaredAgentManifest covers where the `manifest:` key may live on a -// service entry. Service-level properties win over the nested config block so -// the unified azure.yaml shape reads the same way the inline agent definition -// does. -func TestDeclaredAgentManifest(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - svc *azdext.ServiceConfig - want string - }{ - { - name: "nil service", - }, - { - name: "no manifest declared falls back to the convention", - svc: &azdext.ServiceConfig{Name: "agent"}, - }, - { - name: "service-level manifest", - svc: &azdext.ServiceConfig{ - AdditionalProperties: mustStruct(t, map[string]any{"manifest": "agent.yaml"}), - }, - want: "agent.yaml", - }, - { - name: "config-level manifest", - svc: &azdext.ServiceConfig{ - Config: mustStruct(t, map[string]any{"manifest": "nested.yaml"}), - }, - want: "nested.yaml", - }, - { - name: "service-level wins over config-level", - svc: &azdext.ServiceConfig{ - AdditionalProperties: mustStruct(t, map[string]any{"manifest": "outer.yaml"}), - Config: mustStruct(t, map[string]any{"manifest": "inner.yaml"}), - }, - want: "outer.yaml", - }, - { - name: "blank value is treated as undeclared", - svc: &azdext.ServiceConfig{ - AdditionalProperties: mustStruct(t, map[string]any{"manifest": " "}), - }, - }, - { - name: "non-string value is ignored", - svc: &azdext.ServiceConfig{ - AdditionalProperties: mustStruct(t, map[string]any{"manifest": 42}), - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - require.Equal(t, tc.want, declaredAgentManifest(tc.svc)) - }) - } -} - -// TestResolveDeclaredManifestPath pins the confinement rules. A manifest is -// part of one service's source, so it must stay inside that service's project -// directory even when the path would still land inside the azd project. -func TestResolveDeclaredManifestPath(t *testing.T) { - t.Parallel() - - root := t.TempDir() - - tests := []struct { - name string - servicePath string - declared string - wantRel string - wantErr bool - }{ - { - name: "sibling file", - servicePath: "src/triage", - declared: "agent.yaml", - wantRel: filepath.Join("src", "triage", "agent.yaml"), - }, - { - name: "nested file", - servicePath: "src/triage", - declared: "agents/primary.yml", - wantRel: filepath.Join("src", "triage", "agents", "primary.yml"), - }, - { - name: "service at project root", - servicePath: ".", - declared: "agent.yaml", - wantRel: "agent.yaml", - }, - { - name: "escaping the service directory is rejected", - servicePath: "src/triage", - declared: "../other/agent.yaml", - wantErr: true, - }, - { - name: "escaping the project root is rejected", - servicePath: "src/triage", - declared: "../../../agent.yaml", - wantErr: true, - }, - { - name: "absolute paths are rejected", - servicePath: "src/triage", - declared: "/etc/agent.yaml", - wantErr: true, - }, - { - name: "non-YAML extensions are rejected", - servicePath: "src/triage", - declared: "agent.json", - wantErr: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - got, err := resolveDeclaredManifestPath(root, tc.servicePath, tc.declared, "triage-agent") - if tc.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - require.Equal(t, filepath.Join(root, tc.wantRel), got) - }) - } -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go index 858e8c9e5a3..c477d221823 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" ) @@ -127,7 +128,7 @@ func TestResolvePromptAgentGraph_HarnessFeatureGate(t *testing.T) { agent := &agent_yaml.PromptAgent{ Model: "gpt-4.1-mini", Instructions: "ok", - Harness: harness, + Harness: agent_yaml.NewPromptHarness(harness), Policies: []agent_yaml.Policy{ {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: "/subscriptions/sub/raiPolicies/strict"}, }, @@ -137,7 +138,7 @@ func TestResolvePromptAgentGraph_HarnessFeatureGate(t *testing.T) { return agent } - for _, harness := range []string{"github-copilot", ""} { + for _, harness := range []string{agent_api.ManagedAgentHarnessGitHubCopilot, ""} { agent := newAgent(harness, nil) if _, err := p.resolvePromptAgentGraph(t.Context(), agent, nil, nil, nil); err != nil { t.Errorf("harness %q should accept guardrails: %v", harness, err) @@ -150,7 +151,8 @@ func TestResolvePromptAgentGraph_HarnessFeatureGate(t *testing.T) { t.Errorf("a plain prompt agent should accept knowledge: %v", err) } - _, err := p.resolvePromptAgentGraph(t.Context(), newAgent("github-copilot", grounding), nil, nil, nil) + _, err := p.resolvePromptAgentGraph( + t.Context(), newAgent(agent_api.ManagedAgentHarnessGitHubCopilot, grounding), nil, nil, nil) if err == nil { t.Fatal("a harnessed agent declaring knowledge should be rejected") } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go index 2cd68c869f1..82ea5bd03e0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go @@ -148,7 +148,7 @@ func newPromptGraph( if err != nil { return nil, err } - if strings.TrimSpace(managed.Harness) != "" { + if managed.HarnessType() != "" { // An explicit toolbox: reference is a separate feature from skills: it // attaches an existing shared toolbox as an mcp tool. Skills are never // routed through a toolbox of azd's making — the harness already has a diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go index 2d40b90f374..38767b1b2f5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go @@ -12,6 +12,7 @@ import ( "strings" "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/azure" @@ -340,7 +341,8 @@ func skillsShellNode( return exterrors.Validation( exterrors.CodeInvalidAgentManifest, "toolbox: is only available to an agent that names a harness", - "add 'harness: github-copilot' to agent.yaml, or remove 'toolbox:' and put the "+ + "add a 'harness:' block with type "+agent_api.ManagedAgentHarnessGitHubCopilot+ + " to agent.yaml, or remove 'toolbox:' and put the "+ "skills in a skills/ folder next to agent.yaml", ) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go index ea2dea379e5..d821df899b7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go @@ -12,9 +12,16 @@ import ( "strings" "testing" + "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" ) +// testPromptHarness returns a minimal harness block. Each caller gets its own +// value because the deploy graph writes skills back onto the agent. +func testPromptHarness() *agent_yaml.PromptHarness { + return agent_yaml.NewPromptHarness(agent_api.ManagedAgentHarnessGitHubCopilot) +} + // fakeToolboxBuilder records calls and returns a fixed MCP url. type fakeToolboxBuilder struct { mcpURL string @@ -312,7 +319,7 @@ func TestSkillsHarnessNode_NoneReturnsNil(t *testing.T) { // references, and nothing is added to tools. A skill is not a tool, and the // toolbox that used to carry them is service-owned. func TestSkillsHarnessNode_PinsVersionsAndAttachesNoTool(t *testing.T) { - managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: "github-copilot"} + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: testPromptHarness()} managed.Name = "agent" g := &promptGraph{managed: managed, bindings: map[string]any{}} pub := &fakeHarnessSkillPublisher{} @@ -357,7 +364,7 @@ func TestSkillsHarnessNode_PinsVersionsAndAttachesNoTool(t *testing.T) { // the service returning 500 for a reference with no version: azd always sends // the version it just published, whether or not SKILL.md pinned one. func TestSkillsHarnessNode_PinsVersionEvenWhenUnpinned(t *testing.T) { - managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: "github-copilot"} + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: testPromptHarness()} g := &promptGraph{managed: managed, bindings: map[string]any{}} pub := &fakeHarnessSkillPublisher{ published: []publishedSkill{{Name: "skill-a", Version: "3", Pinned: false}}, @@ -380,7 +387,7 @@ func TestSkillsHarnessNode_ResolveIsIdempotent(t *testing.T) { managed := &agent_yaml.PromptAgent{ Model: "m", Instructions: "i", - Harness: "github-copilot", + Harness: testPromptHarness(), HarnessSkills: []agent_yaml.HarnessSkillRef{{Name: "skill-a", Version: "7"}}, } g := &promptGraph{managed: managed, bindings: map[string]any{}} @@ -400,7 +407,7 @@ func TestSkillsHarnessNode_ResolveIsIdempotent(t *testing.T) { } func TestSkillsHarnessNode_RejectsEmptyInstructions(t *testing.T) { - managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: "github-copilot"} + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: testPromptHarness()} g := &promptGraph{managed: managed, bindings: map[string]any{}} pub := &fakeHarnessSkillPublisher{} @@ -420,7 +427,7 @@ func TestSkillsHarnessNode_RejectsEmptyInstructions(t *testing.T) { } func TestSkillsHarnessNode_PublisherErrorPropagates(t *testing.T) { - managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: "github-copilot"} + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: testPromptHarness()} g := &promptGraph{managed: managed, bindings: map[string]any{}} pub := &fakeHarnessSkillPublisher{err: errors.New("boom")} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index f4e8bdd926e..a1aa7649c13 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -208,6 +208,10 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er "run 'azd init' to initialize your project", ) } + // Read the include directive before resolving it away: ResolveServiceConfigInPlace + // replaces the `$ref` key with the referenced file's contents, so this is the + // only point where the file the definition came from is still knowable. + declaredRef := declaredAgentDefinitionRef(p.serviceConfig) if err := ResolveServiceConfigInPlace( p.serviceConfig, proj.Project.Path, @@ -241,7 +245,7 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er // their entire deploy target in the service config, so skip the // subscription/tenant/credential resolution the hosted path needs. if serviceIsPromptAgent(p.serviceConfig) { - return p.resolveAgentDefinitionPath(proj.Project.Path, servicePath, fullPath) + return p.resolveAgentDefinitionPath(proj.Project.Path, servicePath, fullPath, declaredRef) } // Get subscription ID from environment @@ -294,14 +298,18 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er p.projectPath = proj.Project.Path p.servicePath = fullPath - return p.resolveAgentDefinitionPath(proj.Project.Path, servicePath, fullPath) + return p.resolveAgentDefinitionPath(proj.Project.Path, servicePath, fullPath, declaredRef) } // resolveAgentDefinitionPath locates the agent definition (agent.yaml/agent.yml // or the AGENT_DEFINITION_PATH override) for the service and stores it on the // provider. It is shared by the hosted and prompt-agent Initialize paths. +// +// declaredRef is the root `$ref` the service entry carried in azure.yaml before +// the include machinery expanded it, or "" when the service declares none. func (p *AgentServiceTargetProvider) resolveAgentDefinitionPath( projectPath, servicePath, fullPath string, + declaredRef string, ) error { // Check if user has specified agent definition path via environment variable if envPath := os.Getenv("AGENT_DEFINITION_PATH"); envPath != "" { @@ -330,24 +338,26 @@ func (p *AgentServiceTargetProvider) resolveAgentDefinitionPath( return nil } - // Explicit reference: `manifest:` on the service entry names the agent - // definition file. It wins over both the inline shape and the agent.yaml - // convention, so a developer who wants to name the file something else -- - // or keep several manifests side by side in one folder -- can say so in - // azure.yaml instead of relying on a filename azd hardcodes. - if declared := declaredAgentManifest(p.serviceConfig); declared != "" { - resolved, err := resolveDeclaredManifestPath(projectPath, servicePath, declared, p.serviceConfig.Name) + // Explicit reference: a root `$ref:` on the service entry names the file that + // supplies the agent definition. The shared include machinery has already + // merged that file's contents onto the service entry, so a hosted agent needs + // nothing more. A prompt agent does: it reads the raw YAML and anchors the + // skills/ and vector-assets/ convention folders next to the file, so record + // where the file actually lives. + if declaredRef != "" && serviceIsPromptAgent(p.serviceConfig) { + resolved, err := resolveDeclaredRefPath(projectPath, declaredRef, p.serviceConfig.Name) if err != nil { return err } if _, statErr := os.Stat(resolved); statErr != nil { - // A declared-but-missing manifest is a typo, not an opt-out. - // Falling back to the convention here would deploy a different - // manifest than the one azure.yaml names. + // A declared-but-missing target is a typo, not an opt-out. Falling + // back to the convention here would deploy a different file than the + // one azure.yaml names. return exterrors.Dependency( exterrors.CodeAgentDefinitionNotFound, - fmt.Sprintf("agent manifest %q declared by service %q does not exist", declared, p.serviceConfig.Name), - "correct the manifest: path in azure.yaml, or remove it to use the default agent.yaml", + fmt.Sprintf("agent definition %q referenced by service %q does not exist", + declaredRef, p.serviceConfig.Name), + "correct the $ref: path in azure.yaml, or remove it to use the default agent.yaml", ) } p.agentDefinitionPath = resolved @@ -404,20 +414,29 @@ func (p *AgentServiceTargetProvider) resolveAgentDefinitionPath( exterrors.CodeAgentDefinitionNotFound, fmt.Sprintf("agent definition file not found: no agent.yaml or agent.yml found in %s", fullPath), "add an agent.yaml/agent.yml file to the service directory, "+ - "declare manifest: on the service in azure.yaml, or set AGENT_DEFINITION_PATH", + "declare $ref: on the service in azure.yaml, or set AGENT_DEFINITION_PATH", ) } -// AgentManifestServiceKey is the azure.yaml service key that points at the -// agent manifest file, relative to the service's project directory. -const AgentManifestServiceKey = "manifest" - -// declaredAgentManifest returns the manifest path declared on the service entry -// in azure.yaml, or "" when the service relies on the agent.yaml convention. +// AgentDefinitionRefKey is the azure.yaml service key that points at the file +// supplying the agent definition, relative to the project directory. +// +// It is the standard Foundry file-include directive rather than a key azd +// invents: the same `$ref` every other Foundry resource uses, resolved by the +// same machinery (see [foundry.ResolveFileRefs]). Reusing it means one spelling, +// one set of path rules, and one schema for "this entry lives in another file". +const AgentDefinitionRefKey = "$ref" + +// declaredAgentDefinitionRef returns the root `$ref` declared on the service +// entry in azure.yaml, or "" when the service relies on the agent.yaml +// convention or carries its definition inline. +// +// It must be called before [ResolveServiceConfigInPlace], which expands the +// directive and removes the key. // // Service-level properties are checked before the nested config block so the // unified shape wins, matching how the inline agent definition is resolved. -func declaredAgentManifest(svc *azdext.ServiceConfig) string { +func declaredAgentDefinitionRef(svc *azdext.ServiceConfig) string { if svc == nil { return "" } @@ -425,7 +444,7 @@ func declaredAgentManifest(svc *azdext.ServiceConfig) string { if props == nil { continue } - value, ok := props.GetFields()[AgentManifestServiceKey] + value, ok := props.GetFields()[AgentDefinitionRefKey] if !ok { continue } @@ -436,54 +455,37 @@ func declaredAgentManifest(svc *azdext.ServiceConfig) string { return "" } -// resolveDeclaredManifestPath resolves a `manifest:` value against the service -// directory and confines it there. +// resolveDeclaredRefPath resolves a `$ref` value against the project root and +// confines it there. // -// Confinement is to the *service* directory rather than the project root: a -// manifest is part of one service's source, and letting it reach across into a -// sibling service's folder makes the two services silently share state that -// neither declares. -func resolveDeclaredManifestPath(projectPath, servicePath, declared, serviceName string) (string, error) { +// The project root — not the service directory — is the anchor because that is +// what [foundry.ResolveFileRefs] already uses when it expands the same value. +// Anchoring differently here would make the file azd reads for the convention +// folders a different file from the one whose contents were merged onto the +// service entry. +func resolveDeclaredRefPath(projectPath, declared, serviceName string) (string, error) { if filepath.IsAbs(declared) || strings.HasPrefix(declared, "/") || strings.HasPrefix(declared, `\`) { return "", exterrors.Validation( exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("manifest %q on service %q must be a relative path", declared, serviceName), - "use a path relative to the service's project directory (e.g. manifest: agents/triage.yaml)", + fmt.Sprintf("$ref %q on service %q must be a relative path", declared, serviceName), + "use a path relative to the directory holding azure.yaml (e.g. $ref: ./agents/triage.yaml)", ) } - serviceDir, err := paths.JoinAllowRoot(projectPath, servicePath) + resolved, err := paths.JoinAllowRoot(projectPath, filepath.FromSlash(declared)) if err != nil { return "", exterrors.Validation( exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("invalid project path for service %q: %s", serviceName, err), - "update azure.yaml so the service's project directory stays within the project", - ) - } - - resolved, err := paths.JoinAllowRoot(projectPath, servicePath, filepath.FromSlash(declared)) - if err != nil { - return "", exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("invalid manifest path %q on service %q: %s", declared, serviceName, err), - "update azure.yaml so the manifest stays within the service's project directory", - ) - } - - rel, err := filepath.Rel(serviceDir, resolved) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("manifest %q on service %q resolves outside the service directory", declared, serviceName), - "point manifest: at a file inside the service's project directory", + fmt.Sprintf("invalid $ref path %q on service %q: %s", declared, serviceName, err), + "update azure.yaml so the $ref stays within the project directory", ) } if ext := strings.ToLower(filepath.Ext(resolved)); ext != ".yaml" && ext != ".yml" { return "", exterrors.Validation( exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("manifest %q on service %q must be a YAML file (.yaml or .yml)", declared, serviceName), - "point manifest: at a .yaml or .yml file", + fmt.Sprintf("$ref %q on service %q must be a YAML file (.yaml or .yml)", declared, serviceName), + "point $ref: at a .yaml or .yml file", ) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index 307571d6023..f0974958547 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -379,6 +379,83 @@ func TestInitializeAcceptsProjectLocalAgentYaml(t *testing.T) { require.Equal(t, filepath.Join(serviceDir, "agent.yaml"), provider.agentDefinitionPath) } +// TestInitializeResolvesPromptAgentFileRef pins the `$ref` include as the way a +// service entry names its agent definition file. A prompt agent needs the file's +// location, not just its contents: it reads the raw YAML and anchors the skills/ +// and vector-assets/ convention folders next to it. +func TestInitializeResolvesPromptAgentFileRef(t *testing.T) { + t.Setenv("AGENT_DEFINITION_PATH", "") + + projectRoot := t.TempDir() + serviceDir := filepath.Join(projectRoot, "svc") + require.NoError(t, os.MkdirAll(serviceDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(serviceDir, "triage.yaml"), + []byte("kind: prompt\nname: triage\nmodel: gpt-4.1-mini\ninstructions: hi\n"), + 0o600, + )) + + props, err := structpb.NewStruct(map[string]any{"$ref": "./svc/triage.yaml"}) + require.NoError(t, err) + config, err := structpb.NewStruct(map[string]any{ + "promptAgent": map[string]any{"projectEndpoint": "https://example.test"}, + }) + require.NoError(t, err) + + provider := &AgentServiceTargetProvider{ + azdClient: newInitializeTestClient(t, projectRoot), + } + require.NoError(t, provider.Initialize(t.Context(), &azdext.ServiceConfig{ + Name: "triage", + Host: "azure.ai.agent", + RelativePath: "svc", + AdditionalProperties: props, + Config: config, + })) + + require.NoError(t, provider.ensureDeployContext(t.Context())) + require.Equal(t, filepath.Join(serviceDir, "triage.yaml"), provider.agentDefinitionPath) +} + +// TestInitializeRejectsMissingPromptAgentFileRef pins that a `$ref` naming a file +// that is not there is a typo, not an opt-out: falling back to the agent.yaml +// convention would deploy a different definition than azure.yaml names. +func TestInitializeRejectsMissingPromptAgentFileRef(t *testing.T) { + t.Setenv("AGENT_DEFINITION_PATH", "") + + projectRoot := t.TempDir() + serviceDir := filepath.Join(projectRoot, "svc") + require.NoError(t, os.MkdirAll(serviceDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(serviceDir, "agent.yaml"), + []byte("kind: prompt\nname: triage\nmodel: gpt-4.1-mini\ninstructions: hi\n"), + 0o600, + )) + + props, err := structpb.NewStruct(map[string]any{"$ref": "./svc/missing.yaml"}) + require.NoError(t, err) + config, err := structpb.NewStruct(map[string]any{ + "promptAgent": map[string]any{"projectEndpoint": "https://example.test"}, + }) + require.NoError(t, err) + + provider := &AgentServiceTargetProvider{ + azdClient: newInitializeTestClient(t, projectRoot), + } + require.NoError(t, provider.Initialize(t.Context(), &azdext.ServiceConfig{ + Name: "triage", + Host: "azure.ai.agent", + RelativePath: "svc", + AdditionalProperties: props, + Config: config, + })) + + err = provider.ensureDeployContext(t.Context()) + + require.Error(t, err) + require.Empty(t, provider.agentDefinitionPath) +} + func TestInitializeRejectsAgentYamlSymlinkEscapingRoot(t *testing.T) { t.Setenv("AGENT_DEFINITION_PATH", "") diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go index f5351c6b2be..6200ab22a6d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go @@ -231,9 +231,30 @@ func validatePromptAgentRawFields(data []byte) error { ) } } + // `harness` used to be the harness name on its own. It is an object now, so + // the typed decode below would reject a string with a decoder-level type + // error that names neither the old shape nor the new one. + if harness, ok := probe["harness"].(string); ok { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent.yaml sets harness to the string %q, but harness is now a block", harness), + fmt.Sprintf("replace it with:\n harness:\n type: %s", promptHarnessTypeFor(harness)), + ) + } return nil } +// promptHarnessTypeFor maps an old bare harness name to the type to write in the +// new block, so the suggestion above is copy-pasteable even when the name itself +// was also renamed. +func promptHarnessTypeFor(harness string) string { + harness = strings.TrimSpace(harness) + if replacement, removed := agent_api.RemovedManagedAgentHarnesses[harness]; removed { + return replacement + } + return harness +} + // deployPromptAgent creates (or updates) the prompt agent on the managed // harness and registers the resulting agent identity in the azd environment. // It is the prompt-agent analogue of deployHostedAgent, dispatched from From 028eab8a54cb090cfd7febf063d900525c28e5c4 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Wed, 26 Aug 2026 14:13:53 +0530 Subject: [PATCH 18/24] docs(ai-agents): update Agent schema, changelog, and cspell words --- cli/azd/.vscode/cspell.yaml | 2 + .../extensions/azure.ai.agents/CHANGELOG.md | 45 +++++++++-- .../azure.ai.agents/schemas/Agent.json | 76 ++++++++++++++++++- 3 files changed, 117 insertions(+), 6 deletions(-) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index a40517e12c6..aef46027b4d 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -1,6 +1,8 @@ import: ../../../.vscode/cspell.global.yaml words: - braydonk + - builtin + - subagents - osutil - upserted - upserting diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 4547ff00564..f56735f4282 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -2,15 +2,50 @@ ## Unreleased +- **Breaking:** agents that name a harness now reject fields and tool types the harness cannot honor, matching the Foundry GitHub Copilot harness spec. The service fails these at the API rather than ignoring them, so azd now catches them at deploy time and names the offending key: + - `temperature`, `top_p`, `tool_choice` and `text` are rejected — the harness supplies its own sampling parameters and response format. + - `reasoning` accepts only `effort`; any other property is rejected. + - Tool types with no representation in the platform-managed toolbox a harness dispatches through are rejected: `function`, `azure_function`, `bing_grounding`, `capture_structured_outputs`, `image_generation`, `local_shell`, `shell`, `custom`, `computer`, `apply_patch`, `namespace` and `programmatic_tool_calling`. + + None of this narrows what a **harness-less** prompt agent accepts — every field and tool type above still works without `harness:`. The rejection lists are authoritative (taken from the spec), but a tool type absent from them is still passed through, so types newer than your azd build continue to deploy. +- `reminder_preview`, `toolbox_search` and `web_iq_preview` are now recognized tool types, so declaring one no longer produces a spurious "unrecognized tool type" warning. +- **Breaking:** `harness:` in `agent.yaml` is now a block rather than a bare string, matching the managed-agent API: `harness:` with a required `type`, plus optional `skills`, `environment` (`cpu`/`memory`/`idle_timeout_seconds`) and `builtin_tools` (`allowed`/`excluded`). `cpu` and `memory` must be set together, and `builtin_tools` entries are checked against the harness capabilities (`filesystem_read`, `filesystem_write`, `shell`, `subagents`, `web`) so a typo fails locally instead of silently widening what the agent can do. A string value is rejected with the replacement block in the error text. +- **Breaking:** the managed-agent harness type is now spelled `github-copilot` in `agent.yaml` and on `--harness` (was `ghcp`). The old abbreviation is rejected with an error naming the replacement rather than being silently upgraded, so a manifest never disagrees with what is sent to the service. Update `harness: ghcp` to a `harness:` block with `type: github-copilot`. +- The link from an `azure.yaml` service to its agent definition file is now explicit, using the same `$ref` file-include directive every other Foundry resource already uses: `$ref: ./agent.yaml` on the service entry. The referenced file's contents are merged onto the service entry, and a declared file that does not exist is a hard error rather than a silent fallback to the `agent.yaml`/`agent.yml` convention. `AGENT_DEFINITION_PATH` still wins over everything. +- `azd ai agent init` now writes that reference out instead of leaving it to convention: `$ref: ./agent.yaml` on the service entry in `azure.yaml`. Behavior is unchanged for projects that omit it — the convention still applies — but the scaffold now shows the `azure.yaml` → `agent.yaml` edge in the files themselves, so the file can be renamed by editing one line. - Prompt (kind: prompt) agents now support a convention-over-configuration deploy pipeline. `azd up` resolves an internal dependency graph before publishing the agent and validates the whole graph first so a failure never leaves a half-wired agent: - - A sibling `instructions.md` supplies the agent's instructions when none are declared inline (inline wins). - - A non-empty `files/` folder is uploaded to a vector store and wired into an auto-added `file_search` tool (content-hash dedupe; existing `file_search` tools are merged, not duplicated). - - A non-empty `skills/` folder registers each `SKILL.md` bundle into a Foundry toolbox version and attaches its MCP endpoint as an `mcp` tool; an explicit `toolbox:` reference attaches an existing toolbox instead. + - A non-empty `vector-assets/` folder is uploaded to a vector store and wired into an auto-added `file_search` tool (content-hash dedupe; existing `file_search` tools are merged, not duplicated). + - A non-empty `skills/` folder attaches the agent's skills, by one of two mechanisms depending on whether a harness is named. A **managed** agent (`harness:`) has each `SKILL.md` bundle published as a Foundry skill version and pinned onto the `harness` block by name and version, which is where the harness spec puts them: a skill is instructions plus the scripts they reference, so it needs the harness sandbox to run. Foundry provisions it into the harness's own service-owned toolbox — azd creates no toolbox, and a skill never becomes a tool. The version is always sent explicitly, because the service rejects a reference that omits it. A **plain** prompt agent has no sandbox, so its bundles are referenced by name on the definition's own `skills` field and made runnable by an injected `shell` tool. The separate `toolbox:` key is unrelated to skills — it attaches an *existing* shared toolbox as an `mcp` tool, and requires a harness, since a toolbox is only reachable from inside a harness sandbox; `toolbox:` on a harness-less agent is a validation error rather than deploying an agent whose tools never run. + - Folder-authored skills are merged onto the `harness.skills` list alongside any entries authored there by hand, de-duplicated by name with the published (versioned) reference winning. - A `connections:` block resolves through a precedence ladder (use existing, create-if-missing with Entra default, auto-fill target from provisioning outputs, or provision/fail-fast), and each tool's required role is surfaced for assignment. - The model deployment is create-if-missing, and container-only fields (`image`, `protocols`, `code_configuration`, …) are rejected for prompt agents. - The manifest parser recognizes `skill` and `file` resource kinds. -- `azd ai agent init` now scaffolds the prompt-agent authoring layout: an `instructions.md` sidecar (instructions are written there instead of inline in `agent.yaml`) plus an empty `skills/` folder so the deploy conventions are discoverable from a fresh init. -- **Breaking:** the `agent.yaml` discriminator for prompt agents is now `kind: prompt` (was `kind: managed`). Existing `agent.yaml` files must be updated; the scaffolded schema annotation now points at `PromptAgent.yaml`. The `--kind managed` init flag value is still accepted as a backward-compatible alias for `--kind prompt`. +- Prompt agents now support **memory** via a new `memory:` block in `agent.yaml`. `azd` creates the named Foundry memory store if it does not exist (reusing it if it does) and appends a `memory_search_preview` tool bound to it, since the prompt-agent API has no memory field of its own. `scope` defaults to `{{$userId}}` so a shared agent cannot surface one user's memories in another user's conversation. Available on managed agents (`harness: github-copilot`) too; a switch (`harnessedPromptFeatures` in `internal/pkg/agents/agent_yaml/prompt_features.go`) can fail the deploy fast if a harness is ever confirmed to ignore a capability. +- Documented that the portal's **guardrails** and **knowledge** capabilities are already supported through existing keys — `policies:` (a `rai_policy` entry becomes the definition's `rai_config`) and the `vector-assets/` folder plus retrieval entries in `tools:` respectively. Neither is a field on the prompt-agent API, so no new keys were added. +- Prompt agents now support the `temperature:`, `top_p:`, `text:`, and `reasoning:` keys in `agent.yaml`, which previously had no binding and so could not be set at all. `temperature` and `top_p` are nullable, so an explicit `temperature: 0` is sent as `0` rather than collapsing into "unset" and picking up the service default. Together with the existing keys, all eleven fields the prompt-agent API's definition accepts are now reachable from `agent.yaml`. +- `tools:` entries are now validated. The service ignores a tool whose `type` it cannot identify **without reporting an error**, so a typo previously deployed "successfully" and produced an agent silently missing a capability its manifest claimed. Entries that are unambiguously malformed — not a mapping, no `type`, a non-string or blank `type`, or a type the API has removed (`memory_search`, replaced by `memory_search_preview`) — now fail validation before anything is provisioned, naming the offending index. A merely *unrecognized* type is reported as a warning and still deployed, since it may be newer than your azd build; hard-failing would make every new service tool type a breaking change. +- `azd deploy` now warns when it reuses an existing memory store whose live definition differs from what `agent.yaml` declares. Stores are create-if-missing and never updated, so editing `memory.chat_model` for a store that already exists silently had no effect. The warning names both the declared and actual values. It does not fail the deploy, because the store may be shared with another agent whose definition this manifest does not own. +- Deploy now records `AGENT__MEMORY_STORE_NAME` in the azd environment when a `memory:` block contributed a store, alongside the existing `AGENT__VECTOR_STORE_ID`. +- `azd ai agent init` now warns that prompt agents are a preview feature of the azd CLI experience when the plain (harness-less) prompt agent is selected. +- `azd ai agent init` now scaffolds the prompt-agent authoring layout: instructions written inline into `agent.yaml` plus empty `skills/` and `vector-assets/` folders so the deploy conventions are discoverable from a fresh init. +- `azd ai agent init` now carries `displayName:` and `metadata:` from a supplied prompt-agent manifest into the scaffolded `agent.yaml`, alongside the tools, skills, connections and policies it already copied. A hosted agent's template writes these catalog labels into `azure.yaml` and they reach the same fields on the agent-create request for a prompt agent, so a prompt agent scaffolded from a template no longer silently loses them. +- **Breaking:** prompt-agent authoring now matches hosted agents. There are no compatibility fallbacks — existing prompt agents must be updated: + - `instructions` are declared inline in `agent.yaml` and are required. The instructions sidecar (`instructions.md`, later `AGENTS.md`) and the `instructions_file:` key are gone; the manifest now carries the same shape the prompt-agent API accepts, so what you author is what is sent. + - The `version:` key has been removed from `agent.yaml`. It was written back after each deploy and ignored as an input, which made it look editable when it was not. The published version is still recorded in the azd environment as `AGENT__VERSION`. + - The conventional vector-store folder is now `vector-assets/` (was `files/`), naming what it is for rather than what it contains. + - Model deployments now live on a sibling `azure.ai.project` service that the agent service `uses:`, instead of under the agent service's `config.deployments`. `azd ai agent init` emits this shape, so a prompt agent's `azure.yaml` is now structurally identical to a hosted agent's. Projects that already have their deployments under the agent service and no `azure.ai.project` service continue to work. +- **Breaking:** `azd ai agent init` now writes a portable `azure.yaml` that contains no subscription, resource-group, workspace, or endpoint values, so an agent folder can be copied to another machine or subscription and deployed with `azd up` unchanged. Existing projects keep working — values already in the file still win — but newly generated files differ: + - The `azure.ai.project` service key is always the generic `ai-project` (an existing key in the project is still reused). It was previously derived from the Foundry project name, which baked a tenant-specific identifier into the file. + - The project's `endpoint:` is written as `${AZURE_AI_PROJECT_ENDPOINT}` and the concrete URL is stored in the azd environment. The reference is expanded before azd decides whether to reuse an existing Foundry project or create one, so setting the variable reuses a project and leaving it unset provisions a new one from the same `azure.yaml`. + - The `config.promptAgent` block is now written entirely as environment references — `baseUrl: ${AZD_MANAGED_AGENT_BASE_URL}`, `subscriptionId: ${AZURE_SUBSCRIPTION_ID}`, `resourceGroup: ${AZURE_RESOURCE_GROUP}`, `workspace: ${AZURE_AI_WORKSPACE}`, and `projectEndpoint: ${AZURE_AI_PROJECT_ENDPOINT}` — instead of the resolved literals. The references are expanded against the azd environment at deploy time, and a reference whose variable is unset falls back to the built-in default, so a block that cannot be resolved no longer blocks deploy. `init` writes `AZURE_AI_WORKSPACE` into the azd environment alongside `AZURE_AI_PROJECT_ENDPOINT`. Blocks containing literal values keep working unchanged. +- `azd ai agent init` now offers a plain **prompt agent** alongside the harnessed one. Both scaffold `kind: prompt`; the difference is the new optional `harness` field in `agent.yaml`: + - *Prompt agent (no code, Foundry-managed)* — omits `harness`. Foundry runs the model, instructions, and tools directly; there is no Brain+Hand sandbox to provision. + - *Prompt agent with GitHub Copilot harness (preview)* — writes `harness: github-copilot`, the previous behavior. + Non-interactively, use `--kind prompt` or `--kind managed`; `--harness github-copilot|none` overrides the harness implied by the kind. Previously every prompt agent was published with a hard-coded harness, and the field was never written to the scaffolded `agent.yaml`. +- **Breaking:** a prompt agent that names a `harness:` may no longer declare `memory:` or any knowledge/grounding tool (`file_search`, `azure_ai_search`, `bing_grounding`, `sharepoint_grounding_preview`, and the other retrieval types, plus the `file_search` entry azd synthesizes from a `vector-assets/` folder). The harness spec documents RAI policy attachment but puts grounding out of scope and never describes memory, so these are now rejected at deploy time with a message naming the capability, instead of being published and silently dropped. `policies:` (guardrails) is unaffected, and a prompt agent without `harness:` still supports all three. Move an agent that needs memory or its own corpus off the harness by removing the `harness:` key. +- **Breaking:** the `agent.yaml` discriminator for prompt agents is now `kind: prompt` (was `kind: managed`). Existing `agent.yaml` files must be updated; the scaffolded schema annotation now points at `PromptAgent.yaml`. The `--kind managed` init flag value is still accepted, and now selects the GitHub Copilot-harnessed prompt agent. +- **Breaking:** removed `connections[].provision` from `agent.yaml`. The field was reserved but never implemented, and setting it always failed the deploy — a declaration carries only a name, auth type, and metadata, with no resource kind, SKU, or region to create anything from, and creating resources belongs to `azd provision` rather than `azd deploy`. A connection that matches no existing connection and has no resolvable `target` now fails with a single message telling you to provision the resource with infrastructure and set `connections[].target`. Remove the key from any manifest that sets it; nothing else changes. +- Fixed two gaps in memory store handling for prompt agents, caused by `agent.yaml`'s `memory:` block and `azure.yaml`'s `memoryStores:` list carrying independent copies of the same logic. An `options:` block whose fields were all unset was sent to the service as an empty object instead of being omitted, overriding the service defaults the author intended to keep; and drift against an existing store was only reported for `chat_model` and `embedding_model`, so a changed `options:` value was silently ignored. Both surfaces now share one request builder and one drift check, and the drift wording is consistent between them. - Fixed a bug where only `SKILL.md` was uploaded when registering a skill under `skills//` — any other files in the bundle (e.g. `references/`, `assets/`, `scripts/`, at any nesting depth) were silently dropped. Skill registration now uploads the entire bundle via multipart upload instead of sending just the parsed `SKILL.md` body inline. - Fixed a bug where a toolbox attached to a prompt agent (via a `skills/` folder or a `toolbox:` reference) was wired into the agent's `mcp` tool without a `project_connection_id`, leaving the agent with no credential to reach the toolbox MCP endpoint so its skills were never invoked. Deploy now creates (or updates) a `RemoteTool` project connection — via the Microsoft.CognitiveServices control plane, since the data-plane connections API is read-only — that fronts the toolbox endpoint and sets it as the tool's `project_connection_id`. - Fixed a bug where `azd up` re-prompted for an Azure region for a prompt agent even after an existing Foundry project was selected during init. Selecting an existing project now seeds `AZURE_LOCATION` from the project's region (in addition to `AZURE_AI_DEPLOYMENTS_LOCATION`), so the model is deployed to the project's region without a redundant prompt. diff --git a/cli/azd/extensions/azure.ai.agents/schemas/Agent.json b/cli/azd/extensions/azure.ai.agents/schemas/Agent.json index b9881b7850e..cc1f38f55bb 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/Agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/Agent.json @@ -156,9 +156,83 @@ "tools": true, "skill": true, "metadata": true, + "model": { + "type": "string", + "description": "Name of the model deployment the agent runs on (for example \"gpt-4.1-mini\"). Must match a deployment declared on the sibling azure.ai.project service." + }, "instructions": { "type": "string", - "description": "Inline prompt text or a relative path to a markdown file the extension reads at deploy time." + "description": "The system/developer message inserted into the model's context. Declared inline." + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2, + "description": "Sampling temperature. Lower values make output more deterministic. Omit to use the model default; prefer setting either temperature or top_p, not both." + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Nucleus sampling cutoff. Omit to use the model default; prefer setting either temperature or top_p, not both." + }, + "text": { + "type": "object", + "description": "Configuration for the model's text response, most commonly the structured output format (for example text.format.type: json_schema)." + }, + "reasoning": { + "type": "object", + "description": "Configuration for reasoning-model behavior (for example reasoning.effort). Only meaningful on models that support reasoning." + }, + "memory": { + "type": "object", + "description": "Durable recall carried across invocations, backed by a Foundry memory store. The store is created if it does not exist, and a memory_search_preview tool bound to it is added to the agent's tools automatically.", + "required": ["store", "chat_model", "embedding_model"], + "additionalProperties": false, + "properties": { + "store": { + "type": "string", + "description": "Name of the memory store. Created if it does not already exist, reused if it does." + }, + "description": { + "type": "string", + "description": "Description recorded on the memory store when it is created." + }, + "chat_model": { + "type": "string", + "description": "Model deployment name the store uses to summarize conversations into memories." + }, + "embedding_model": { + "type": "string", + "description": "Model deployment name the store uses to embed memories for retrieval." + }, + "scope": { + "type": "string", + "description": "Namespace that isolates memories, typically per user. Defaults to \"{{$userId}}\", which resolves the caller's object ID at runtime so one user's memories never surface in another's conversation." + }, + "update_delay": { + "type": "integer", + "minimum": 0, + "description": "Seconds of conversation inactivity to wait before extracting memories. Omit to use the service default (300). Low values extract on nearly every turn and are intended for demos." + }, + "max_memories": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of memories a single search returns. Omit to use the service default." + }, + "options": { + "type": "object", + "description": "Toggles for which memory kinds the store extracts. Omitted toggles keep the service default.", + "additionalProperties": false, + "properties": { + "chat_summary_enabled": { "type": "boolean" }, + "user_profile_enabled": { "type": "boolean" }, + "procedural_memory_enabled": { "type": "boolean" }, + "default_ttl_seconds": { "type": "integer", "minimum": 0 }, + "user_profile_details": { "type": "string" } + } + } + } } } } From 101be1362eb7b417ff1403b4cf9479398ce33358 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Wed, 26 Aug 2026 21:12:15 +0530 Subject: [PATCH 19/24] feat(ai-agents): RAI policy discovery with fallback, and prompt agent fixes Adds RAI policy support end to end: init discovers the policies on the Foundry account and records one in the environment, and the deploy graph gains a policy node that lists the account's policies to verify the declared name. A missing policy is now a warning that falls back to the built-in default rather than a hard failure -- the account applies its own default content filters to an agent that names no policy at all, so the fallback leaves the agent no less filtered than publishing it without guardrails would. Also fixes three defects found while exercising the $ref authoring path: - Agent definitions were decoded into the inline hosted-agent shape before the kind was checked, so a prompt agent's string "model" failed to unmarshal into the hosted Model struct. The kind gate now runs first. - rai_policy_name was validated before environment expansion, which rejected ${RAI_POLICY_ID} for not being a full ARM resource ID. Unexpanded values are now deferred to the deploy path, where the expanded value is validated. - Connection authType "Entra" -- the spelling azd's own documentation and scaffolding used -- has never been accepted by the service, whose discriminator for that mode is AAD. It is now normalized rather than forwarded verbatim into a bad request that lists twenty-one auth types without explaining that the two name the same thing. Replaces the standalone Foundry skills client with the shared skills path, and updates the agent schema accordingly. --- .../azure.ai.agents/internal/cmd/delete.go | 2 +- .../azure.ai.agents/internal/cmd/init.go | 87 +++-- .../internal/cmd/init_from_code.go | 4 +- .../cmd/init_from_templates_helpers.go | 151 ++++---- .../cmd/init_from_templates_helpers_test.go | 109 +++--- .../internal/cmd/init_infra.go | 10 +- .../internal/cmd/init_managed.go | 73 ++-- .../internal/cmd/init_managed_foundry.go | 32 +- .../cmd/init_managed_manifest_test.go | 68 +--- .../internal/cmd/init_rai_policy.go | 242 ++++++++++++ .../internal/cmd/init_rai_policy_test.go | 137 +++++++ .../azure.ai.agents/internal/cmd/listen.go | 9 +- .../internal/cmd/resource_services.go | 158 +++++++- .../internal/cmd/resource_services_test.go | 146 ++++++- .../azure.ai.agents/internal/cmd/show_test.go | 10 +- .../internal/exterrors/codes.go | 1 + .../agent_api/managed_operations_test.go | 2 +- .../internal/pkg/agents/agent_api/models.go | 21 +- .../internal/pkg/agents/agent_yaml/map.go | 4 +- .../pkg/agents/agent_yaml/prompt_features.go | 14 +- .../agents/agent_yaml/prompt_features_test.go | 29 +- .../internal/pkg/agents/agent_yaml/yaml.go | 23 +- .../pkg/agents/agent_yaml/yaml_test.go | 10 + .../pkg/azure/foundry_rai_policies.go | 135 +++++++ .../pkg/azure/foundry_rai_policies_test.go | 112 ++++++ .../pkg/azure/foundry_skills_client.go | 267 ------------- .../pkg/azure/foundry_skills_client_test.go | 173 --------- .../internal/project/agent_definition.go | 40 +- .../internal/project/agent_definition_test.go | 37 +- .../internal/project/config.go | 21 + .../internal/project/doc_examples_test.go | 7 + .../internal/project/foundry_dependencies.go | 12 +- .../internal/project/prompt_connections.go | 43 ++- .../project/prompt_connections_test.go | 32 ++ .../internal/project/prompt_graph.go | 26 +- .../internal/project/prompt_policy_node.go | 197 ++++++++++ .../project/prompt_policy_node_test.go | 277 ++++++++++++++ .../internal/project/prompt_skills.go | 361 ++++++++---------- .../project/prompt_skills_bundle_test.go | 69 ---- .../internal/project/prompt_skills_test.go | 247 ++++++------ .../internal/project/service_target_prompt.go | 111 +++++- .../internal/synthesis/synthesizer.go | 14 +- .../schemas/azure.ai.agent.json | 39 ++ .../internal/synthesis/synthesizer.go | 14 +- 44 files changed, 2441 insertions(+), 1135 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_rai_policies.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_rai_policies_test.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_policy_node.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_policy_node_test.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_bundle_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go index 928e490121b..c8d310de378 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go @@ -478,7 +478,7 @@ func (a *DeleteAction) runPromptDelete( if envResp, envErr := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); envErr == nil { cleanupAgentSessionState(ctx, azdClient, envResp.Environment.Name, pctx.ServiceName) } - a.cleanupEnvVars(ctx, azdClient, pctx.ServiceName) + a.cleanupEnvVars(ctx, azdClient, pctx.ServiceName, pctx.Settings.ProjectEndpoint) switch a.flags.output { case "json": diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index bb36d5dd3d9..5ba9f1b9a2c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -84,17 +84,19 @@ type initFlags struct { // `azd config reset`, and `azd infra generate`. force bool // kind, when set, explicitly selects the agent runtime ("hosted", - // "prompt", "managed", or "prompt-voice") and bypasses the interactive kind + // "prompt", or "prompt-voice") and bypasses the interactive kind // prompt. This is primarily for non-interactive callers (--no-prompt) and // automation; interactive users get the kind prompt when this is empty. + // A harnessed ("managed") agent is not one of these values: it is "prompt" + // plus a --harness. // "prompt-voice" synthesizes a declarative (managed) voice agent manifest and // routes it through the manifest flow (no code/image, no template/language // selection, no ACR). kind string // harness, when set, names the execution harness written to the scaffolded - // prompt agent.yaml (only "github-copilot" is supported today). It overrides - // the harness implied by --kind, so `--kind prompt --harness github-copilot` - // is equivalent to `--kind managed`. Ignored for hosted agents. + // prompt agent.yaml (only "github_copilot_preview" is supported today). A + // harness is what makes a prompt agent a "managed" agent; there is no + // separate --kind for it. Ignored for hosted agents. harness string // noPrompt is resolved from the extension context (--no-prompt / AZD_NO_PROMPT) // and is not registered as a CLI flag on the init command itself. @@ -106,6 +108,9 @@ type initFlags struct { // and `--infra=bicep` are explicit. The eject runs after a fresh init or // standalone when azure.yaml already exists. infra string + // raiPolicy selects the Responsible AI policy a prompt or managed agent + // binds to. Empty means "ask" (or, with --no-prompt, attach nothing). + raiPolicy string } // AiProjectResourceConfig represents the configuration for an AI project resource @@ -795,6 +800,11 @@ func synthesizeImageManifestFile(agentName, image string, flagProtocols []string // kindFlagPromptVoice is the accepted --kind value for a declarative voice agent. const kindFlagPromptVoice = "prompt-voice" +// kindFlagRemovedManaged is the retired --kind value for a harnessed prompt +// agent. It is matched only so the flag can be rejected with the replacement +// spelling; a managed agent is `--kind prompt --harness github_copilot_preview`. +const kindFlagRemovedManaged agentKindChoice = "managed" + // synthesizeVoiceManifestFile writes a temporary declarative (managed) voice // agent manifest (kind: prompt-voice) to a temp dir and returns its path plus a // cleanup func. Like synthesizeImageManifestFile, it lets `--kind prompt-voice` @@ -1349,9 +1359,9 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // // An explicit --kind flag always wins: it bypasses both the prompt // and the hosted-signal gating so automation can select a - // prompt-agent runtime non-interactively. Both prompt kinds share the - // same init flow and the same agent.yaml `kind: prompt`; they differ - // only in the harness written to the manifest. + // prompt-agent runtime non-interactively. A harnessed ("managed") + // agent is not a kind of its own — it is `--kind prompt` plus + // `--harness`, and both scaffold agent.yaml with `kind: prompt`. // // A supplied --manifest (or positional template) that declares // `kind: prompt` also routes here, with or without --kind, so a @@ -1361,14 +1371,27 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // fetch of a remote pointer. requestedKind := agentKindChoice(strings.ToLower(strings.TrimSpace(flags.kind))) isPromptVoice := strings.EqualFold(strings.TrimSpace(flags.kind), kindFlagPromptVoice) + if requestedKind == kindFlagRemovedManaged { + // Named separately from the generic "unknown value" case: this + // value used to work, so the error owes the user the two flags + // that replace it rather than only the list of what is allowed. + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--kind managed is not a valid kind", + fmt.Sprintf( + "a managed agent is a prompt agent with a harness; "+ + "use --kind prompt --harness %s instead", + agent_api.ManagedAgentHarnessGitHubCopilot, + ), + ) + } if flags.kind != "" && !isPromptVoice && requestedKind != AgentKindChoiceHosted && - requestedKind != AgentKindChoicePrompt && - requestedKind != AgentKindChoiceManaged { + requestedKind != AgentKindChoicePrompt { return exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf("unknown --kind value %q", flags.kind), - "supported values are: hosted, prompt, managed, prompt-voice", + "supported values are: hosted, prompt, prompt-voice", ) } @@ -1381,8 +1404,10 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } switch { - case requestedKind == AgentKindChoicePrompt || requestedKind == AgentKindChoiceManaged: - harness, harnessErr := resolveInitHarness(flags.harness, requestedKind) + case requestedKind == AgentKindChoicePrompt: + // No implied harness on the flag path: --harness is the only way + // to ask for one, and omitting it scaffolds a plain prompt agent. + harness, harnessErr := resolveInitHarness(flags.harness, "") if harnessErr != nil { return harnessErr } @@ -1391,7 +1416,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // No --kind: the manifest's own harness decides the flavor, so a // template with a `harness:` block scaffolds a managed agent and a // harness-less one a plain prompt agent. --harness still wins. - harness, harnessErr := resolveManifestInitHarness( + harness, harnessErr := resolveInitHarness( flags.harness, promptManifest.definition.HarnessType(), ) if harnessErr != nil { @@ -1405,12 +1430,12 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, flags.runtime != "" || flags.entryPoint != "" if !hostedSignalsPresent { - kindChoice, kindErr := promptAgentKind(ctx, azdClient, flags.noPrompt) + kindChoice, kindHarness, kindErr := promptAgentKind(ctx, azdClient, flags.noPrompt) if kindErr != nil { return kindErr } - if kindChoice == AgentKindChoicePrompt || kindChoice == AgentKindChoiceManaged { - harness, harnessErr := resolveInitHarness(flags.harness, kindChoice) + if kindChoice == AgentKindChoicePrompt { + harness, harnessErr := resolveInitHarness(flags.harness, kindHarness) if harnessErr != nil { return harnessErr } @@ -2029,16 +2054,16 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, cmd.Flags().StringVar(&flags.kind, "kind", "", "Agent runtime to initialize: 'hosted' (bring your own code/container), 'prompt' "+ - "(model + instructions; Foundry runs the agent directly, no harness), 'managed' "+ - "(a prompt agent that additionally runs on the GitHub Copilot Brain+Hand harness), or "+ - "'prompt-voice' (a declarative voice agent; use --model for the speech-to-speech model "+ - "and --voice for the output voice). When omitted, "+ + "(model + instructions; Foundry runs the agent), or 'prompt-voice' (a declarative "+ + "voice agent; use --model for the speech-to-speech model and --voice for the output "+ + "voice). A managed agent is not a separate kind: pair 'prompt' with "+ + "--harness github_copilot_preview to run it on the Brain+Hand harness. When omitted, "+ "the kind is taken from --manifest when it declares one, otherwise you are prompted "+ - "interactively. With --no-prompt, 'prompt' and 'managed' require --agent-name and "+ + "interactively. With --no-prompt, 'prompt' requires --agent-name and "+ "either --model or --model-deployment (unless supplied by --manifest).") cmd.Flags().StringVar(&flags.harness, "harness", "", - "Execution harness for a prompt agent: 'github-copilot' (GitHub Copilot Brain+Hand) "+ - "or 'none'. Overrides the harness implied by --kind. Ignored for hosted agents.") + "Execution harness for a prompt agent: 'github_copilot_preview' (GitHub Copilot "+ + "Brain+Hand) or 'none'. Ignored for hosted agents.") cmd.Flags().StringVar(&flags.infra, "infra", "", "Eject infrastructure-as-code from azure.yaml. Existing infrastructure is preserved and "+ "Foundry files are generated as a separate infra/foundry layer. "+ @@ -2051,6 +2076,14 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // `--infra=terraform` / `--infra=bicep`. Absent flag stays "" (no eject). cmd.Flags().Lookup("infra").NoOptDefVal = project.BicepProviderName + cmd.Flags().StringVar(&flags.raiPolicy, "rai-policy", "", + "Responsible AI policy for a prompt or managed agent: 'none' to inherit the account's "+ + "default content filters, a policy name on the selected Foundry account, or a policy's "+ + "full ARM resource ID. The policy must already exist; azd attaches it, it does not "+ + "create it. When omitted, you are prompted to pick from the policies on the account; "+ + "with --no-prompt no policy is attached. "+ + "Ignored for hosted agents and when --manifest already declares policies.") + return cmd } @@ -3532,7 +3565,11 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa emittedConnections, err := emitResourceServices( ctx, a.azdClient, a.serviceNameOverride, endpointRef, - resourceDeployments, resourceConnections, resourceToolboxes, + foundryResources{ + Deployments: resourceDeployments, + Connections: resourceConnections, + Toolboxes: resourceToolboxes, + }, ) if err != nil { return err @@ -3624,7 +3661,7 @@ func (a *InitAction) addVoiceAgentToProject( if _, err := emitResourceServices( ctx, a.azdClient, a.serviceNameOverride, endpointRef, - nil, nil, nil, + foundryResources{}, ); err != nil { return err } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index 54c978a47d3..f70267e118a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -887,18 +887,16 @@ func (a *InitFromCodeAction) addToProject( // and wire the agent's uses: to it. A selected existing project contributes // its endpoint so provision reuses it instead of creating a new project. The // endpoint itself lives in the azd environment; azure.yaml only references it. - agentServiceName := strings.ReplaceAll(agentName, " ", "") endpointRef, err := recordFoundryProjectEnv( ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject, ) if err != nil { return err } - // its endpoint so provision reuses it instead of creating a new project. if _, err := emitResourceServices( ctx, a.azdClient, agentServiceName, endpointRef, - resourceDeployments, nil, nil, + foundryResources{Deployments: resourceDeployments}, ); err != nil { return err } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go index 994be170da6..62647309cfb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go @@ -113,9 +113,12 @@ const ( // agentKindChoice represents the discriminator the user picks at the very // start of `azd ai agent init`. It selects between the supported agent -// runtimes: hosted (the container/code-deploy flow), prompt (a plain Foundry -// prompt agent with no harness), and managed (a prompt agent driven by the -// Foundry Brain+Hand harness, currently powered by GitHub Copilot). +// runtimes: hosted (the container/code-deploy flow) and prompt (a Foundry +// prompt agent). +// +// There is deliberately no "managed" choice. A managed agent is a prompt agent +// that names an execution harness, so the harness is an independent dimension +// (--harness) rather than a kind of its own; both scaffold `kind: prompt`. type agentKindChoice string const ( @@ -123,43 +126,31 @@ const ( // supplies code or a container image and the platform runs it on Azure // Container Apps. AgentKindChoiceHosted agentKindChoice = "hosted" - // AgentKindChoicePrompt is the plain "prompt" agent path — the customer - // declares model + instructions and Foundry runs the agent directly, with - // no harness and no sandbox to provision. The scaffolded agent.yaml uses - // kind: prompt (see agent_yaml.AgentKindPrompt) and omits `harness`. + // AgentKindChoicePrompt is the prompt agent path — the customer declares + // model + instructions and Foundry runs the agent. The scaffolded agent.yaml + // uses kind: prompt (see agent_yaml.AgentKindPrompt). Whether it also names + // a `harness:` is decided separately, by --harness or the kind menu entry. AgentKindChoicePrompt agentKindChoice = "prompt" - // AgentKindChoiceManaged is the managed-agent path — a prompt agent that - // additionally names an execution harness (GitHub Copilot), so Foundry - // provisions a Brain+Hand sandbox for it. The scaffolded agent.yaml still - // uses kind: prompt; the only difference is a `harness:` block naming the - // harness type. - AgentKindChoiceManaged agentKindChoice = "managed" ) -// harnessForKindChoice returns the agent.yaml `harness` value implied by a kind -// choice. Managed agents run on the GitHub Copilot harness; plain prompt agents -// have none, so the field is omitted from agent.yaml and the create request. -func harnessForKindChoice(choice agentKindChoice) string { - if choice == AgentKindChoiceManaged { - return agent_api.ManagedAgentHarnessGitHubCopilot - } - return "" -} - // harnessNone is the --harness value that explicitly opts out of a harness, -// letting `--kind managed --harness none` degrade to a plain prompt agent. +// letting `--harness none` degrade a harnessed template to a plain prompt agent. const harnessNone = "none" // resolveInitHarness resolves the harness written to the scaffolded agent.yaml. -// An explicit --harness value always wins over the harness implied by the kind -// choice, so `--kind prompt --harness github-copilot` and -// `--kind managed` are equivalent. -func resolveInitHarness(harnessFlag string, choice agentKindChoice) (string, error) { - harness := strings.ToLower(strings.TrimSpace(harnessFlag)) +// An explicit --harness value always wins over impliedHarness — the harness the +// context already suggests, whether that is the menu entry the user picked or +// the `harness:` block of a supplied manifest. Both are validated the same way, +// so a harness that is no longer accepted is reported wherever it came from. +func resolveInitHarness(harnessFlag, impliedHarness string) (string, error) { + requested := harnessFlag + if strings.TrimSpace(requested) == "" { + requested = impliedHarness + } + + harness := strings.ToLower(strings.TrimSpace(requested)) switch harness { - case "": - return harnessForKindChoice(choice), nil - case harnessNone: + case "", harnessNone: return "", nil case agent_api.ManagedAgentHarnessGitHubCopilot: return agent_api.ManagedAgentHarnessGitHubCopilot, nil @@ -177,40 +168,61 @@ func resolveInitHarness(harnessFlag string, choice agentKindChoice) (string, err return "", exterrors.Validation( exterrors.CodeInvalidParameter, - fmt.Sprintf("unknown --harness value %q", harnessFlag), + fmt.Sprintf("unknown --harness value %q", requested), fmt.Sprintf("supported values are: %s, %s", agent_api.ManagedAgentHarnessGitHubCopilot, harnessNone), ) } -// promptAgentKind asks the user which agent kind to initialize. In no-prompt -// mode it returns AgentKindChoiceHosted to preserve today's behavior for CI -// callers that do not yet know about the new kinds. The selection is the very -// first interactive prompt in `azd ai agent init` and routes the rest of the -// init flow. +// kindMenuEntry is one row of the interactive kind picker. A row maps to a +// (kind, harness) pair rather than to a kind alone, because the harnessed +// prompt agent differs from the plain one only by its `harness:` block. Keeping +// the harness on the entry lets the menu offer it as a single choice without +// reintroducing a "managed" kind that nothing downstream understands. +type kindMenuEntry struct { + label string + kind agentKindChoice + harness string +} + +// agentKindMenu is the ordered set of rows shown by promptAgentKind. +var agentKindMenu = []kindMenuEntry{ + { + label: "Hosted agent — Bring your own code or framework", + kind: AgentKindChoiceHosted, + }, + { + label: "Prompt agent (no code, Foundry-managed) — " + + "Configure a model, instructions, and tools", + kind: AgentKindChoicePrompt, + }, + { + label: "Prompt agent with GitHub Copilot harness (preview) — " + + "Configure a model, instructions, tools, and skills", + kind: AgentKindChoicePrompt, + harness: agent_api.ManagedAgentHarnessGitHubCopilot, + }, +} + +// promptAgentKind asks the user which agent kind to initialize, returning the +// kind and the harness that choice implies. In no-prompt mode it returns +// AgentKindChoiceHosted to preserve today's behavior for CI callers that do not +// yet know about the new kinds. The selection is the very first interactive +// prompt in `azd ai agent init` and routes the rest of the init flow. func promptAgentKind( ctx context.Context, azdClient *azdext.AzdClient, noPrompt bool, -) (agentKindChoice, error) { +) (agentKindChoice, string, error) { if noPrompt { - return AgentKindChoiceHosted, nil + return AgentKindChoiceHosted, "", nil } - choices := []*azdext.SelectChoice{ - { - Label: "Hosted agent — Bring your own code or framework", - Value: string(AgentKindChoiceHosted), - }, - { - Label: "Prompt agent (no code, Foundry-managed) — " + - "Configure a model, instructions, and tools", - Value: string(AgentKindChoicePrompt), - }, - { - Label: "Prompt agent with GitHub Copilot harness (preview) — " + - "Configure a model, instructions, tools, and skills", - Value: string(AgentKindChoiceManaged), - }, + choices := make([]*azdext.SelectChoice, 0, len(agentKindMenu)) + for _, entry := range agentKindMenu { + choices = append(choices, &azdext.SelectChoice{ + Label: entry.label, + Value: string(entry.kind), + }) } defaultIndex := int32(0) @@ -223,27 +235,30 @@ func promptAgentKind( }) if err != nil { if exterrors.IsCancellation(err) { - return "", exterrors.Cancelled("agent kind selection was cancelled") + return "", "", exterrors.Cancelled("agent kind selection was cancelled") } - return "", fmt.Errorf("failed to prompt for agent kind: %w", err) + return "", "", fmt.Errorf("failed to prompt for agent kind: %w", err) } - choice := agentKindChoice(choices[*resp.Value].Value) - warnPromptAgentPreview(os.Stdout, choice) - return choice, nil + // Two menu rows share the value "prompt", so the answer is resolved by + // index. Guard it: an out-of-range index would otherwise pick a harness at + // random or panic. + selected := int(*resp.Value) + if selected < 0 || selected >= len(agentKindMenu) { + return "", "", fmt.Errorf("agent kind selection returned an out-of-range index %d", selected) + } + + entry := agentKindMenu[selected] + return entry.kind, entry.harness, nil } // warnPromptAgentPreview tells the user that prompt-agent support in azd is -// still in preview. The harnessed option already carries "(preview)" in its -// label, so only the plain prompt agent needs the callout; without it that -// option reads as generally available next to the hosted one. +// still in preview. It is called from the single place every prompt-agent init +// funnels through, so the notice also reaches flag-driven runs (--kind prompt) +// and manifest-driven ones, not just the interactive picker. // // This warns rather than blocks: preview is a stability signal, not a gate. -func warnPromptAgentPreview(writer io.Writer, choice agentKindChoice) { - if choice != AgentKindChoicePrompt { - return - } - +func warnPromptAgentPreview(writer io.Writer) { // Each segment is colored independently. Nesting output.WithBold inside // output.WithWarningFormat would emit a reset mid-string, dropping the // surrounding yellow and switching the foreground to white from there on. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go index db6dffefc03..04097771bea 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go @@ -21,26 +21,26 @@ func TestResolveInitHarness(t *testing.T) { t.Parallel() tests := []struct { - name string - harnessFlag string - choice agentKindChoice - expected string - expectErr bool + name string + harnessFlag string + impliedHarness string + expected string + expectErr bool }{ { - name: "prompt kind has no harness", - choice: AgentKindChoicePrompt, + name: "no flag and no implied harness scaffolds a plain prompt agent", expected: "", }, { - name: "managed kind implies the github-copilot harness", - choice: AgentKindChoiceManaged, - expected: agent_api.ManagedAgentHarnessGitHubCopilot, + // The harnessed menu row and a manifest's `harness:` block both + // arrive here as an implied value. + name: "implied harness is honored", + impliedHarness: agent_api.ManagedAgentHarnessGitHubCopilot, + expected: agent_api.ManagedAgentHarnessGitHubCopilot, }, { - name: "explicit harness overrides prompt kind", - harnessFlag: "GitHub-Copilot", - choice: AgentKindChoicePrompt, + name: "explicit harness is accepted case-insensitively", + harnessFlag: "GitHub_Copilot_Preview", expected: agent_api.ManagedAgentHarnessGitHubCopilot, }, { @@ -49,19 +49,30 @@ func TestResolveInitHarness(t *testing.T) { // service no longer knows. name: "removed ghcp spelling is rejected", harnessFlag: "ghcp", - choice: AgentKindChoicePrompt, expectErr: true, }, { - name: "none opts out of the managed harness", - harnessFlag: " none ", - choice: AgentKindChoiceManaged, - expected: "", + name: "none opts out of an implied harness", + harnessFlag: " none ", + impliedHarness: agent_api.ManagedAgentHarnessGitHubCopilot, + expected: "", + }, + { + name: "explicit harness overrides a harness-less context", + harnessFlag: agent_api.ManagedAgentHarnessGitHubCopilot, + impliedHarness: "", + expected: agent_api.ManagedAgentHarnessGitHubCopilot, + }, + { + // A manifest can name a harness azd no longer accepts; it is + // validated on the same path as the flag rather than passed through. + name: "removed implied harness is rejected", + impliedHarness: "ghcp", + expectErr: true, }, { name: "unknown harness is rejected", harnessFlag: "bogus", - choice: AgentKindChoicePrompt, expectErr: true, }, } @@ -70,7 +81,7 @@ func TestResolveInitHarness(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - harness, err := resolveInitHarness(tc.harnessFlag, tc.choice) + harness, err := resolveInitHarness(tc.harnessFlag, tc.impliedHarness) if tc.expectErr { require.Error(t, err) return @@ -81,38 +92,44 @@ func TestResolveInitHarness(t *testing.T) { } } -// TestWarnPromptAgentPreview verifies the preview callout fires for the plain -// prompt agent and stays silent for the other kinds. The harnessed option -// already says "(preview)" in its label, and hosted agents are GA. -func TestWarnPromptAgentPreview(t *testing.T) { +// TestAgentKindMenuHasNoManagedKind guards the invariant behind removing the +// managed kind: the harnessed row is a prompt agent that carries a harness, not +// a kind of its own. A row reintroducing one would scaffold an agent.yaml the +// schema rejects. +func TestAgentKindMenuHasNoManagedKind(t *testing.T) { t.Parallel() - tests := []struct { - name string - choice agentKindChoice - wantWarn bool - }{ - {name: "prompt agent warns", choice: AgentKindChoicePrompt, wantWarn: true}, - {name: "managed agent stays quiet", choice: AgentKindChoiceManaged}, - {name: "hosted agent stays quiet", choice: AgentKindChoiceHosted}, + var harnessed int + for _, entry := range agentKindMenu { + require.Contains( + t, + []agentKindChoice{AgentKindChoiceHosted, AgentKindChoicePrompt}, + entry.kind, + "menu entry %q uses an unsupported kind", entry.label, + ) + if entry.harness != "" { + harnessed++ + require.Equal(t, AgentKindChoicePrompt, entry.kind, + "only a prompt agent can carry a harness") + } } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() + require.Equal(t, 1, harnessed, "expected exactly one harnessed menu entry") +} + +// TestWarnPromptAgentPreview verifies the preview callout renders its +// emphasized segment intact. It is unconditional: every prompt-agent init +// funnels through the one call site, so the notice reaches --kind prompt and +// manifest adoption as well as the interactive picker. +func TestWarnPromptAgentPreview(t *testing.T) { + t.Parallel() - buf := &bytes.Buffer{} - warnPromptAgentPreview(buf, tc.choice) + buf := &bytes.Buffer{} + warnPromptAgentPreview(buf) - if !tc.wantWarn { - require.Empty(t, buf.String()) - return - } - // The emphasized phrase is a separately colored segment, so assert - // it survives concatenation intact rather than being split. - require.Contains(t, buf.String(), "preview feature of the azd CLI experience") - }) - } + // The emphasized phrase is a separately colored segment, so assert + // it survives concatenation intact rather than being split. + require.Contains(t, buf.String(), "preview feature of the azd CLI experience") } func TestEffectiveType(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go index a4260d9eb00..52b569d53b8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go @@ -500,7 +500,9 @@ func infraEjectNeedsEnvironment(projectRoot string) (bool, error) { if err != nil { return false, err } - endpoint, err := synthesis.ProjectEndpoint(rawYAML, serviceName, projectRoot) + // No azd environment is available yet: this call only decides whether one is + // needed, so ${VAR} references fall back to the process environment. + endpoint, err := synthesis.ProjectEndpoint(rawYAML, serviceName, projectRoot, nil) if err != nil { return false, exterrors.Validation( exterrors.CodeInvalidAzureYaml, @@ -539,7 +541,11 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) if err != nil { return err } - endpoint, err := synthesis.ProjectEndpoint(rawYAML, svcName, projectRoot) + var ejectEnv map[string]string + if len(environments) > 0 { + ejectEnv = environments[0] + } + endpoint, err := synthesis.ProjectEndpoint(rawYAML, svcName, projectRoot, ejectEnv) if err != nil { return exterrors.Validation( exterrors.CodeInvalidAzureYaml, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go index f5d2f5043cc..ff3d8e85393 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go @@ -158,20 +158,8 @@ func loadPromptManifestFromPointer( return loadPromptAgentManifest(content, sourceDir) } -// resolveManifestInitHarness resolves the harness for a prompt-agent manifest -// adopted without an explicit --kind. An explicit --harness always wins; -// otherwise the manifest's own harness type is honored, so a template that -// declares one scaffolds a managed agent and one that declares none scaffolds a -// plain prompt agent. -func resolveManifestInitHarness(harnessFlag, manifestHarness string) (string, error) { - if strings.TrimSpace(harnessFlag) != "" { - return resolveInitHarness(harnessFlag, AgentKindChoicePrompt) - } - return resolveInitHarness(manifestHarness, AgentKindChoicePrompt) -} - // runInitManaged is the entry point for `azd ai agent init` when the user has -// selected one of the prompt agent kinds. It produces a first-class azd project +// selected the prompt agent kind. It produces a first-class azd project // so prompt agents follow the same `azd up` / `azd deploy` lifecycle as hosted // agents: // @@ -187,7 +175,7 @@ func resolveManifestInitHarness(harnessFlag, manifestHarness string) (string, er // // harness selects the prompt agent flavor. An empty harness scaffolds a plain // prompt agent that Foundry runs directly; a non-empty harness -// ("github-copilot") +// ("github_copilot_preview") // scaffolds a managed agent whose Brain+Hand sandbox the platform provisions. // // manifest, when non-nil, seeds the agent name, description, model, and @@ -200,6 +188,12 @@ func runInitManaged( harness string, manifest *promptAgentManifest, ) error { + // Every prompt-agent init converges here — interactive picker, --kind prompt, + // and manifest adoption alike — so this is the one place the preview notice + // reaches all of them. Emitted before validation so it is seen even when the + // run is about to fail on a missing --no-prompt input. + warnPromptAgentPreview(os.Stdout) + // Fail before anything is written when non-interactive mode is missing an // input that has no deterministic fallback. ensureProject below creates a // project folder and azd environment, so a late failure would strand a @@ -209,7 +203,7 @@ func runInitManaged( } // Prompt for the conceptual agent details first: name and description. - agentName, err := promptManagedAgentName(ctx, azdClient, flags, manifest) + agentName, err := promptManagedAgentName(ctx, azdClient, flags, manifest, harness) if err != nil { return err } @@ -292,7 +286,7 @@ func runInitManaged( // it a --no-prompt scaffold would carry only placeholder routing values and // `azd up` would fail to find a Foundry project. var model string - deployment, foundryProject, err := resolvePromptHarnessTarget(ctx, azdClient, flags, env, &settings) + deployment, foundryProject, credential, err := resolvePromptHarnessTarget(ctx, azdClient, flags, env, &settings) if err != nil { return err } @@ -306,6 +300,15 @@ func runInitManaged( } } + // Resolve guardrails against the same Foundry account the model was + // resolved on, while its credential is still in hand. Nothing is written + // yet: the selection is applied after the manifest carry-over below so an + // authored policy set is never silently replaced. + raiPolicy, err := resolvePromptRaiPolicy(ctx, azdClient, flags, manifest, foundryProject, credential) + if err != nil { + return err + } + // cwd is now the project root. Create the service directory when nested. if serviceRelPath != "." { if err := os.MkdirAll(serviceRelPath, osutil.PermissionDirectory); err != nil { @@ -351,6 +354,12 @@ func runInitManaged( desc := strings.TrimSpace(description) promptAgent.AgentDefinition.Description = &desc } + // Applied after the manifest carry-over so a manifest that declares its own + // policies keeps them; resolvePromptRaiPolicy returns "not attached" in that + // case, making this a no-op. + if err := applyRaiPolicySelection(ctx, azdClient, env.Name, &promptAgent, raiPolicy); err != nil { + return err + } if err := writePromptAgentYAML(serviceRelPath, &promptAgent); err != nil { return err } @@ -366,14 +375,21 @@ func runInitManaged( return err } - // Model deployments live on a sibling azure.ai.project service, not on the - // agent service, so a prompt agent's azure.yaml has the same shape as a - // hosted agent's. emitResourceServices also wires the agent's uses: list so - // `azd provision` creates the project (and its deployments) first. + // Model deployments, connections and skills live on sibling Foundry + // services, not on the agent service, so a prompt agent's azure.yaml has the + // same shape as a hosted agent's and each host is owned by the extension + // that implements it. emitResourceServices also wires the agent's uses: list + // so `azd provision` creates the project (and its deployments) first and + // `azd deploy` publishes the skills before the agent that references them. var deployments []project.Deployment if deployment != nil { deployments = []project.Deployment{*deployment} } + resources, err := promptResourceServices(ctx, azdClient, &promptAgent, serviceRelPath) + if err != nil { + return err + } + resources.Deployments = deployments endpointRef, err := recordFoundryProjectEnv(ctx, azdClient, env.Name, foundryProject) if err != nil { return err @@ -381,7 +397,7 @@ func runInitManaged( if _, err := emitResourceServices( ctx, azdClient, agentName, endpointRef, - deployments, nil, nil, + resources, ); err != nil { return err } @@ -494,6 +510,18 @@ func validateManagedNoPromptInputs(flags *initFlags, manifest *promptAgentManife return nil } +// defaultPromptAgentName returns the suggested agent name for the flavor being +// scaffolded. The two flavors get distinct defaults because they produce +// different projects: accepting the default twice in the same folder would +// otherwise collide, and the name is also the Foundry agent identity, where a +// reused name silently creates a new version of the existing agent. +func defaultPromptAgentName(harness string) string { + if strings.TrimSpace(harness) != "" { + return "my-copilot-agent" + } + return "my-prompt-agent" +} + // promptManagedAgentName asks for the agent's name. The name is the Foundry // agent identity and (for a fresh project) the project folder name. It matches // the hosted flow's message, help text, and validation so the two flows feel @@ -504,6 +532,7 @@ func promptManagedAgentName( azdClient *azdext.AzdClient, flags *initFlags, manifest *promptAgentManifest, + harness string, ) (string, error) { if strings.TrimSpace(flags.agentName) != "" { return validateInitAgentName(flags.agentName) @@ -520,7 +549,7 @@ func promptManagedAgentName( ) } if defaultName == "" { - defaultName = "my-prompt-agent" + defaultName = defaultPromptAgentName(harness) } resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go index 7561ea69417..ea9c303331f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go @@ -24,7 +24,9 @@ import ( // settings from the selected/created project, and returns the resolved model // deployment to persist to azure.yaml along with the selected existing project // (nil when a new one will be provisioned), which the caller needs to name and -// mark the sibling azure.ai.project service. +// mark the sibling azure.ai.project service, and the credential resolved for +// the chosen subscription so later steps can read the account without +// re-authenticating. // // Location is NOT prompted separately: for an existing project it is derived // from the project; for a new project it is prompted only at that point — the @@ -41,10 +43,10 @@ func resolvePromptHarnessTarget( flags *initFlags, env *azdext.Environment, settings *project.PromptAgentSettings, -) (*project.Deployment, *FoundryProjectInfo, error) { +) (*project.Deployment, *FoundryProjectInfo, azcore.TokenCredential, error) { azureContext, err := loadAzureContext(ctx, azdClient, env.Name) if err != nil { - return nil, nil, err + return nil, nil, nil, err } // A full project resource ID already names its subscription, so seed the @@ -66,9 +68,9 @@ func resolvePromptHarnessTarget( if strings.TrimSpace(flags.projectResourceId) == "" && shouldDeferInitAzureContext(flags.noPrompt, azureContext) { if err := configureDeferredInitAzureContext(ctx, azdClient, env.Name, azureContext, true); err != nil { - return nil, nil, err + return nil, nil, nil, err } - return nil, nil, nil + return nil, nil, nil, nil } // Subscription only — location is resolved per project branch below. @@ -77,14 +79,14 @@ func resolvePromptHarnessTarget( "Select an Azure subscription to find your Foundry project and models.", ) if err != nil { - return nil, nil, err + return nil, nil, nil, err } proj, err := selectPromptFoundryProject( ctx, azdClient, cred, azureContext, env.Name, flags.projectResourceId, flags.noPrompt, ) if err != nil { - return nil, nil, err + return nil, nil, nil, err } if proj == nil { @@ -95,10 +97,10 @@ func resolvePromptHarnessTarget( "with the model deployment you choose next.", )) if err := ensureLocation(ctx, azdClient, azureContext, env.Name); err != nil { - return nil, nil, err + return nil, nil, nil, err } if err := setEnvValue(ctx, azdClient, env.Name, "USE_EXISTING_AI_PROJECT", "false"); err != nil { - return nil, nil, err + return nil, nil, nil, err } if err := updatePendingProjectSignal(ctx, azdClient, env.Name, false); err != nil { log.Printf("warning: failed to update project provision signal: %v", err) @@ -106,7 +108,7 @@ func resolvePromptHarnessTarget( // A new project is provisioned by `azd up`; the harness workspace tuple // is filled from the provisioned env values at deploy time (overlay). deployment, err := resolvePromptModelDeployment(ctx, azdClient, azureContext, env, flags) - return deployment, nil, err + return deployment, nil, cred, err } // Existing project: populate the harness target and derive the location @@ -125,7 +127,7 @@ func resolvePromptHarnessTarget( azureContext.Scope.Location = proj.Location if proj.Location != "" { if err := setEnvValue(ctx, azdClient, env.Name, "AZURE_AI_DEPLOYMENTS_LOCATION", proj.Location); err != nil { - return nil, nil, err + return nil, nil, nil, err } // Also seed AZURE_LOCATION from the selected project's region. The // infra main.parameters.json resolves `location` from ${AZURE_LOCATION}; @@ -133,22 +135,22 @@ func resolvePromptHarnessTarget( // (and thus the target region) is already known. Deploy the model using // the project's region. if err := setEnvValue(ctx, azdClient, env.Name, "AZURE_LOCATION", proj.Location); err != nil { - return nil, nil, err + return nil, nil, nil, err } } if err := setPromptFoundryProjectEnv(ctx, azdClient, env.Name, proj); err != nil { - return nil, nil, err + return nil, nil, nil, err } if err := setEnvValue(ctx, azdClient, env.Name, "USE_EXISTING_AI_PROJECT", "true"); err != nil { - return nil, nil, err + return nil, nil, nil, err } if err := updatePendingProjectSignal(ctx, azdClient, env.Name, true); err != nil { log.Printf("warning: failed to update project provision signal: %v", err) } deployment, err := resolvePromptModelForExistingProject(ctx, azdClient, cred, azureContext, env, flags, proj) - return deployment, proj, err + return deployment, proj, cred, err } // selectPromptFoundryProject lists the Foundry projects in the subscription and diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go index e9bcba803b0..a7083e0f384 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go @@ -7,6 +7,8 @@ import ( "reflect" "testing" + "github.com/stretchr/testify/require" + "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" ) @@ -24,7 +26,7 @@ func TestLooksLikePromptAgentManifest(t *testing.T) { }, { name: "prompt agent with harness", - content: "kind: prompt\nname: my-agent\nmodel: gpt-4.1-mini\nharness:\n type: github-copilot\n", + content: "kind: prompt\nname: my-agent\nmodel: gpt-4.1-mini\nharness:\n type: github_copilot_preview\n", want: true, }, { @@ -64,7 +66,7 @@ func TestLoadPromptAgentManifest(t *testing.T) { "name: triage-agent\n" + "description: Triages incoming issues\n" + "model: gpt-4.1\n" + - "harness:\n type: github-copilot\n skills:\n - name: summarize\n version: \"2\"\n" + + "harness:\n type: github_copilot_preview\n skills:\n - name: summarize\n version: \"2\"\n" + "instructions: You triage issues.\n" + "displayName: Triage Agent\n" + "metadata:\n tags:\n - Prompt Agent\n" + @@ -144,52 +146,24 @@ func TestPromptAgentManifest_NilAccessors(t *testing.T) { } } -func TestResolveManifestInitHarness(t *testing.T) { - tests := []struct { - name string - harnessFlag string - manifestHarness string - want string - wantErr bool - }{ - { - name: "manifest harness is honored", - manifestHarness: "github-copilot", - want: "github-copilot", - }, - {name: "no harness anywhere means plain prompt agent"}, - { - name: "harness flag wins", - harnessFlag: "github-copilot", - want: "github-copilot", - }, - { - name: "harness none overrides manifest", - harnessFlag: "none", - manifestHarness: "github-copilot", - want: "", - }, - {name: "unknown flag value", harnessFlag: "bogus", wantErr: true}, - {name: "unknown manifest value", manifestHarness: "bogus", wantErr: true}, - } +// The manifest's `harness:` block is now resolved by the same +// resolveInitHarness used for --harness and the kind menu, so its precedence +// and validation are covered by TestResolveInitHarness. - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := resolveManifestInitHarness(tt.harnessFlag, tt.manifestHarness) - if tt.wantErr { - if err == nil { - t.Fatal("expected an error") - } - return - } - if err != nil { - t.Fatalf("resolveManifestInitHarness: %v", err) - } - if got != tt.want { - t.Errorf("harness = %q, want %q", got, tt.want) - } - }) - } +// The two prompt flavors scaffold different projects into the same folder, so +// their suggested names must not collide. +func TestDefaultPromptAgentName(t *testing.T) { + t.Parallel() + + plain := defaultPromptAgentName("") + harnessed := defaultPromptAgentName(agent_api.ManagedAgentHarnessGitHubCopilot) + + require.NotEmpty(t, plain) + require.NotEmpty(t, harnessed) + require.NotEqual(t, plain, harnessed) + // Whitespace is treated as "no harness" so a blank flag value cannot + // silently pick the harnessed default. + require.Equal(t, plain, defaultPromptAgentName(" ")) } // The non-interactive guard runs before ensureProject writes anything, so these diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy.go new file mode 100644 index 00000000000..ce24677c4c5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy.go @@ -0,0 +1,242 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" +) + +const ( + // raiPolicyEnvVar names the azd environment variable holding the Responsible + // AI policy's full ARM resource ID. + // + // agent.yaml references the variable rather than the ID itself: the ID + // embeds a subscription, resource group and account, so writing it literally + // would pin the scaffold to the machine that ran init. This mirrors how the + // promptAgent block in azure.yaml already states its Foundry target. + raiPolicyEnvVar = "RAI_POLICY_ID" + + // raiPolicyRef is the value written into agent.yaml's policies[].raiPolicyName. + raiPolicyRef = "${" + raiPolicyEnvVar + "}" + + // raiPolicyFlagNone is the only symbolic --rai-policy value; anything else + // is a policy name or a full ARM resource ID. + raiPolicyFlagNone = "none" +) + +// raiPolicySelection is the outcome of resolving a Responsible AI policy for a +// freshly scaffolded prompt or managed agent. +// +// Attached is false for the default "no policy" choice, in which case the agent +// inherits the account's default content filters and nothing is written. +type raiPolicySelection struct { + // Attached reports whether agent.yaml should declare a policies[] entry. + Attached bool + // ResourceID is the concrete ARM resource ID to record in the azd + // environment. + ResourceID string + // PolicyName is the policy's short name, used for display. + PolicyName string +} + +// resolvePromptRaiPolicy decides which Responsible AI policy a scaffolded +// prompt or managed agent binds to. +// +// azd attaches an existing policy; it does not create one. A policy is an +// account-scoped compliance resource that is frequently shared across agents +// and owned by a different team than the one scaffolding this project, so +// creating one as a side effect of init would be presumptuous. The docs carry +// a worked example for authors who do want to provision one themselves. +// +// A manifest that already declares policies wins outright: the author stated +// their guardrails and init must not second-guess them. Otherwise --rai-policy +// selects non-interactively, and an interactive run lists the policies that +// already exist on the target Foundry account so the common case is a pick +// rather than a resource ID the developer has to go and look up. +// +// foundryProject is nil when init is going to create a new Foundry project, in +// which case there is no account to enumerate. +func resolvePromptRaiPolicy( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, + manifest *promptAgentManifest, + foundryProject *FoundryProjectInfo, + credential azcore.TokenCredential, +) (raiPolicySelection, error) { + if manifest != nil && len(manifest.definition.Policies) > 0 { + return raiPolicySelection{}, nil + } + + requested := strings.TrimSpace(flags.raiPolicy) + switch { + case strings.EqualFold(requested, raiPolicyFlagNone): + return raiPolicySelection{}, nil + case requested != "": + return raiPolicySelectionFromFlag(requested, foundryProject) + } + + // No policy is the safe default for a non-interactive run: the account's + // own default filters still apply, and attaching a policy the caller did + // not ask for would change how the agent answers. + if flags.noPrompt { + return raiPolicySelection{}, nil + } + + return promptForRaiPolicy(ctx, azdClient, foundryProject, credential) +} + +// raiPolicySelectionFromFlag interprets a non-symbolic --rai-policy value as +// either a full ARM resource ID or a policy name on the resolved account. +func raiPolicySelectionFromFlag( + requested string, + foundryProject *FoundryProjectInfo, +) (raiPolicySelection, error) { + if ref, ok := azure.ParseRaiPolicyResourceID(requested); ok { + return raiPolicySelection{ + Attached: true, + ResourceID: requested, + PolicyName: ref.PolicyName, + }, nil + } + + if foundryProject == nil { + return raiPolicySelection{}, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("--rai-policy %q is a policy name, but no existing Foundry account was selected", requested), + "pass the policy's full ARM resource ID, or select an existing Foundry project "+ + "so the name can be resolved against its account", + ) + } + + return raiPolicySelection{ + Attached: true, + ResourceID: azure.RaiPolicyResourceID( + foundryProject.SubscriptionId, + foundryProject.ResourceGroupName, + foundryProject.AccountName, + requested, + ), + PolicyName: requested, + }, nil +} + +// promptForRaiPolicy asks the developer to pick a policy, listing the ones that +// already exist on the account. +// +// A failure to list is not fatal. Reading RAI policies needs a role the +// developer may not hold, and a missing list should cost them the convenience +// of a picker rather than the ability to finish init. +func promptForRaiPolicy( + ctx context.Context, + azdClient *azdext.AzdClient, + foundryProject *FoundryProjectInfo, + credential azcore.TokenCredential, +) (raiPolicySelection, error) { + var existing []azure.RaiPolicyInfo + if foundryProject != nil && credential != nil { + policies, err := azure.ListRaiPolicies( + ctx, credential, + foundryProject.SubscriptionId, + foundryProject.ResourceGroupName, + foundryProject.AccountName, + ) + if err != nil { + fmt.Println(color.HiBlackString( + "Could not list Responsible AI policies on %q: %v", foundryProject.AccountName, err, + )) + } else { + existing = policies + } + } + + // Nothing to choose between. Showing a one-option picker whose only answer + // is the default wastes a prompt, and azd has no policy to offer to create. + if len(existing) == 0 { + return raiPolicySelection{}, nil + } + + choices := []*azdext.SelectChoice{{ + Label: "No Responsible AI policy (use the account's default content filters)", + Value: raiPolicyFlagNone, + }} + for _, policy := range existing { + label := policy.Name + if policy.SystemManaged { + label += " (built-in)" + } + if policy.BasePolicyName != "" { + label += fmt.Sprintf(" - based on %s", policy.BasePolicyName) + } + choices = append(choices, &azdext.SelectChoice{Label: label, Value: policy.ResourceID}) + } + + resp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a Responsible AI policy for this agent", + Choices: choices, + HelpMessage: "A Responsible AI policy applies content filters to the agent's prompts and " + + "completions. The agent references the policy through " + raiPolicyRef + " in agent.yaml, " + + "so the project stays portable across subscriptions. Create a policy with " + + "`az cognitiveservices account rai-policy create` and re-run to see it here.", + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return raiPolicySelection{}, exterrors.Cancelled("Responsible AI policy selection was cancelled") + } + return raiPolicySelection{}, exterrors.Dependency( + exterrors.CodePromptFailed, + fmt.Sprintf("failed to select a Responsible AI policy: %s", err), + "pass --rai-policy none or --rai-policy to skip the interactive selection", + ) + } + + selected := int(*resp.Value) + if selected <= 0 || selected > len(existing) { + return raiPolicySelection{}, nil + } + + policy := existing[selected-1] + return raiPolicySelection{ + Attached: true, + ResourceID: policy.ResourceID, + PolicyName: policy.Name, + }, nil +} + +// applyRaiPolicySelection records the selection on the scaffold: the agent +// declares the policy through the environment reference, and the concrete +// resource ID lands in the azd environment. +func applyRaiPolicySelection( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + promptAgent *agent_yaml.PromptAgent, + selection raiPolicySelection, +) error { + if !selection.Attached || selection.ResourceID == "" { + return nil + } + + promptAgent.Policies = []agent_yaml.Policy{{ + Type: agent_yaml.PolicyTypeRai, + RaiPolicyName: raiPolicyRef, + }} + + if err := setEnvValue(ctx, azdClient, envName, raiPolicyEnvVar, selection.ResourceID); err != nil { + return fmt.Errorf("recording %s: %w", raiPolicyEnvVar, err) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy_test.go new file mode 100644 index 00000000000..a9900bac1fc --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy_test.go @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/stretchr/testify/require" +) + +const testRaiPolicyID = "/subscriptions/sub-1/resourceGroups/my-rg/providers/" + + "Microsoft.CognitiveServices/accounts/my-account/raiPolicies/strict" + +func testFoundryProject() *FoundryProjectInfo { + return &FoundryProjectInfo{ + SubscriptionId: "sub-1", + ResourceGroupName: "my-rg", + AccountName: "my-account", + ProjectName: "my-project", + } +} + +// TestResolvePromptRaiPolicyFlags covers the non-interactive selections, which +// are the only ones a scripted init can take. +func TestResolvePromptRaiPolicyFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flags initFlags + project *FoundryProjectInfo + want raiPolicySelection + }{ + { + name: "none detaches", + flags: initFlags{raiPolicy: "none"}, + want: raiPolicySelection{}, + }, + { + name: "none is case insensitive", + flags: initFlags{raiPolicy: "NONE"}, + want: raiPolicySelection{}, + }, + { + name: "full resource id is used verbatim", + flags: initFlags{raiPolicy: testRaiPolicyID}, + want: raiPolicySelection{ + Attached: true, ResourceID: testRaiPolicyID, PolicyName: "strict", + }, + }, + { + name: "short name resolves against the selected account", + flags: initFlags{raiPolicy: "strict"}, + project: testFoundryProject(), + want: raiPolicySelection{ + Attached: true, ResourceID: testRaiPolicyID, PolicyName: "strict", + }, + }, + { + // Nothing is attached rather than something being guessed: a policy + // the caller did not ask for changes how the agent answers. + name: "no flag with no prompt attaches nothing", + flags: initFlags{noPrompt: true}, + want: raiPolicySelection{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := resolvePromptRaiPolicy(t.Context(), nil, &test.flags, nil, test.project, nil) + require.NoError(t, err) + require.Equal(t, test.want, got) + }) + } +} + +// TestResolvePromptRaiPolicyShortNameWithoutAccount verifies a name that cannot +// be resolved fails with a message that names the fix, rather than producing a +// malformed ID the service would reject much later. +func TestResolvePromptRaiPolicyShortNameWithoutAccount(t *testing.T) { + t.Parallel() + + _, err := resolvePromptRaiPolicy( + t.Context(), nil, &initFlags{raiPolicy: "strict"}, nil, nil, nil, + ) + require.ErrorContains(t, err, "no existing Foundry account was selected") +} + +// TestResolvePromptRaiPolicyManifestWins verifies an authored policy set is not +// second-guessed: init neither prompts nor overwrites it. +func TestResolvePromptRaiPolicyManifestWins(t *testing.T) { + t.Parallel() + + manifest := &promptAgentManifest{ + definition: agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: testRaiPolicyID}, + }, + }, + } + + got, err := resolvePromptRaiPolicy( + t.Context(), nil, &initFlags{raiPolicy: "none"}, manifest, testFoundryProject(), nil, + ) + require.NoError(t, err) + require.Equal(t, raiPolicySelection{}, got) +} + +// TestApplyRaiPolicySelectionDetached verifies the no-policy choice leaves the +// scaffold untouched, so existing behavior is unchanged for agents that do not +// use guardrails. +func TestApplyRaiPolicySelectionDetached(t *testing.T) { + t.Parallel() + + promptAgent := agent_yaml.PromptAgent{} + require.NoError(t, applyRaiPolicySelection( + t.Context(), nil, "dev", &promptAgent, raiPolicySelection{}, + )) + require.Empty(t, promptAgent.Policies) +} + +// TestPromptForRaiPolicyWithoutAccount verifies init does not prompt when there +// is no account to enumerate. azd cannot create a policy, so a picker whose +// only entry is "no policy" would ask a question with one possible answer. +func TestPromptForRaiPolicyWithoutAccount(t *testing.T) { + t.Parallel() + + // A nil client would panic if the picker were reached. + got, err := promptForRaiPolicy(t.Context(), nil, nil, nil) + require.NoError(t, err) + require.Equal(t, raiPolicySelection{}, got) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 4dae7e4de3d..67bf6245a37 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -24,6 +24,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/output" + "google.golang.org/protobuf/proto" ) // configureExtensionHost wires the service target and event handlers on the @@ -546,11 +547,13 @@ func resolveAgentServiceConfigWithProjectOverrides( svc *azdext.ServiceConfig, projectRoot string, ) (*azdext.ServiceConfig, error) { - resolvedSvc := *svc - if err := project.ResolveServiceConfigInPlace(&resolvedSvc, projectRoot); err != nil { + // ServiceConfig is a protobuf message, so clone it rather than dereferencing + // (a shallow copy would copy the embedded message state and its mutex). + resolvedSvc := proto.Clone(svc).(*azdext.ServiceConfig) + if err := project.ResolveServiceConfigInPlace(resolvedSvc, projectRoot); err != nil { return nil, err } - return &resolvedSvc, nil + return resolvedSvc, nil } func warnLegacySimpleTeamsArtifacts(proj *azdext.ProjectConfig, svc *azdext.ServiceConfig) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go index 41a2684259a..cbd9c4cf988 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -6,10 +6,14 @@ package cmd import ( "context" "fmt" + "maps" "os" + "path" + "path/filepath" "slices" "strings" + "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -29,6 +33,10 @@ const ( AiConnectionHost = "azure.ai.connection" // AiToolboxHost owns a single Foundry toolbox (toolset). AiToolboxHost = "azure.ai.toolbox" + // AiSkillHost owns a single Foundry skill and its versions. azd never + // uploads a skill bundle itself; it emits one service per skills// + // folder and attaches the version that extension publishes. + AiSkillHost = "azure.ai.skill" // aiProjectServiceName is the stable azure.yaml service key used for the // single azure.ai.project service. A stable name keeps repeated inits @@ -76,11 +84,105 @@ func promptAgentEnvRefs() *project.PromptAgentSettings { } } +// promptResourceServices derives the sibling Foundry services a prompt or +// managed agent needs from its scaffolded definition and folder layout, so a +// prompt agent's azure.yaml carries the same hosts as a hosted agent's. +// +// - Each entry under connections: becomes an azure.ai.connection service. +// - Each skills/

/ folder becomes an azure.ai.skill service keyed by the +// name its SKILL.md declares. The agents extension never uploads a bundle +// itself; at deploy time it attaches the version the skill service +// published. +// - toolbox: names an existing toolbox rather than defining one, so there is +// nothing to write as a service. Its name is added to the agent's uses: when +// a toolbox service of that name is already in azure.yaml, which is what +// orders the toolbox ahead of the agent; a uses: entry naming a service that +// does not exist would fail the project load instead. +// +// Deployments are left to the caller, which owns the model selection flow. +func promptResourceServices( + ctx context.Context, + azdClient *azdext.AzdClient, + promptAgent *agent_yaml.PromptAgent, + serviceRelPath string, +) (foundryResources, error) { + resources := foundryResources{} + + for _, conn := range promptAgent.Connections { + resources.Connections = append(resources.Connections, project.Connection{ + Name: conn.Name, + Category: conn.Category, + Target: conn.Target, + AuthType: conn.AuthType, + Credentials: conn.Credentials, + Metadata: conn.Metadata, + }) + } + + bundles, err := project.ScanSkillBundles(serviceRelPath) + if err != nil { + return foundryResources{}, err + } + for _, bundle := range bundles { + if resources.Skills == nil { + resources.Skills = map[string]project.SkillService{} + } + resources.Skills[bundle.Name] = project.SkillService{ + Description: bundle.Description, + // Relative to azure.yaml, which lives in the directory init runs in. + Archive: "./" + path.Join(filepath.ToSlash(serviceRelPath), bundle.RelPath), + } + } + + if promptAgent.Toolbox != nil { + name := sanitizeServiceName(promptAgent.Toolbox.Name) + if name != "" && serviceHasHost(ctx, azdClient, name, AiToolboxHost) { + resources.ExtraUses = append(resources.ExtraUses, name) + } + } + + return resources, nil +} + +// serviceHasHost reports whether azure.yaml already defines a service named +// name with the given host. Errors are treated as "no", because the callers use +// it to decide whether adding a uses: edge is safe and the conservative answer +// is to leave the edge out. +func serviceHasHost(ctx context.Context, azdClient *azdext.AzdClient, name, host string) bool { + resp, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil || resp.GetProject() == nil { + return false + } + svc, ok := resp.GetProject().GetServices()[name] + return ok && svc.GetHost() == host +} + +// foundryResources are the Foundry resources an agent depends on, each written +// to azure.yaml as its own sibling service entry keyed by the resource name. +// Grouping them keeps emitResourceServices readable as the set of hosts grows; +// a zero value emits only the always-present azure.ai.project service. +type foundryResources struct { + // Deployments are the model deployments carried by the project service. + Deployments []project.Deployment + // Connections become one azure.ai.connection service each. + Connections []project.Connection + // Toolboxes become one azure.ai.toolbox service each. + Toolboxes []project.Toolbox + // Skills become one azure.ai.skill service each, keyed by skill name. + Skills map[string]project.SkillService + // ExtraUses are service keys added to the agent's uses: list without + // emitting a service for them. A prompt agent's `toolbox:` names an + // *existing* toolbox, so there is no definition to write, but the edge is + // still needed for ordering and for the deploy-time dependency check. + ExtraUses []string +} + // emitResourceServices writes the Foundry resource sibling services that the // agent depends on (one azure.ai.project carrying the model deployments, one -// azure.ai.connection per connection, one azure.ai.toolbox per toolbox) and -// wires the agent service's uses: list to them for ordering. Each resource is -// its own azure.yaml service entry so a different extension can own each host. +// azure.ai.connection per connection, one azure.ai.toolbox per toolbox, one +// azure.ai.skill per skill bundle) and wires the agent service's uses: list to +// them for ordering. Each resource is its own azure.yaml service entry so a +// different extension can own each host. // // projectEndpoint, when non-empty, is written as endpoint: on the project // service to mark an existing (brownfield) Foundry project so provision @@ -92,9 +194,7 @@ func emitResourceServices( azdClient *azdext.AzdClient, agentServiceName string, projectEndpoint string, - deployments []project.Deployment, - connections []project.Connection, - toolboxes []project.Toolbox, + resources foundryResources, ) (int, error) { var agentUses []string emittedConnections := 0 @@ -127,7 +227,7 @@ func emitResourceServices( // provisioning order. A non-empty endpoint marks an existing project. projectCfg, err := project.MarshalStruct(&project.ServiceTargetAgentConfig{ Endpoint: projectEndpoint, - Deployments: deployments, + Deployments: resources.Deployments, }) if err != nil { return 0, fmt.Errorf("marshaling project service config: %w", err) @@ -141,12 +241,12 @@ func emitResourceServices( } agentUses = append(agentUses, projectServiceName) - // Connection and toolbox services depend on the project service so the - // project is provisioned first. + // Connection, toolbox and skill services depend on the project service so + // the project is provisioned first. siblingUses := []string{projectServiceName} - for i := range connections { - conn := connections[i] + for i := range resources.Connections { + conn := resources.Connections[i] connName := sanitizeServiceName(conn.Name) if connName == "" { fmt.Fprintf(os.Stderr, @@ -169,8 +269,8 @@ func emitResourceServices( emittedConnections++ } - for i := range toolboxes { - toolbox := toolboxes[i] + for i := range resources.Toolboxes { + toolbox := resources.Toolboxes[i] toolboxName := sanitizeServiceName(toolbox.Name) if toolboxName == "" { fmt.Fprintf(os.Stderr, @@ -192,6 +292,38 @@ func emitResourceServices( agentUses = append(agentUses, toolboxName) } + // The service key is the skill name the azure.ai.skills extension creates, + // and the name the agent's SKILL.md declares, so iterate in sorted order to + // keep repeated inits byte-identical. + for _, skill := range slices.Sorted(maps.Keys(resources.Skills)) { + skillName := sanitizeServiceName(skill) + if skillName == "" { + fmt.Fprintf(os.Stderr, + "warning: skill %q has no characters usable as an azure.yaml service key; "+ + "skipping it. Rename the skill so it is written to azure.yaml.\n", + skill) + continue + } + if err := reserveServiceName(usedNames, skillName, fmt.Sprintf("skill %q", skill)); err != nil { + return 0, err + } + definition := resources.Skills[skill] + skillCfg, err := project.MarshalStruct(&definition) + if err != nil { + return 0, fmt.Errorf("marshaling skill service %q config: %w", skillName, err) + } + if err := addResourceService(ctx, azdClient, skillName, AiSkillHost, skillCfg, siblingUses); err != nil { + return 0, err + } + agentUses = append(agentUses, skillName) + } + + for _, name := range resources.ExtraUses { + if name != "" && !slices.Contains(agentUses, name) { + agentUses = append(agentUses, name) + } + } + // Wire the agent service to its resource siblings so azd walks them first. if len(agentUses) > 0 && agentServiceName != "" { if err := setServiceUses(ctx, azdClient, agentServiceName, agentUses); err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go index adbb735eb96..c77050e1e5a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -7,10 +7,12 @@ import ( "context" "net" "os" + "path" "path/filepath" "sync" "testing" + "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -674,7 +676,7 @@ func TestEmitResourceServices_AlwaysEmitsProjectService(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - _, err := emitResourceServices(t.Context(), client, "myagent", "", nil, nil, nil) + _, err := emitResourceServices(t.Context(), client, "myagent", "", foundryResources{}) require.NoError(t, err) server.mu.Lock() @@ -686,6 +688,132 @@ func TestEmitResourceServices_AlwaysEmitsProjectService(t *testing.T) { assert.Equal(t, []string{aiProjectServiceName}, server.uses["myagent"]) } +// TestPromptResourceServices covers the translation from a prompt agent's own +// definition and folder layout into the sibling services init writes, which is +// what gives prompt agents the same host coverage hosted agents already have. +func TestPromptResourceServices(t *testing.T) { + t.Parallel() + + t.Run("connections and skill bundles become siblings", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + bundle := filepath.Join(dir, "skills", "code-review") + require.NoError(t, os.MkdirAll(bundle, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(bundle, "SKILL.md"), []byte( + "---\nname: code-review\ndescription: reviews code\n---\n\nDo the review.\n", + ), 0o600)) + + client := newProjectRecorderClient(t, &recordingProjectServer{}) + agent := &agent_yaml.PromptAgent{ + Connections: []agent_yaml.PromptConnection{ + {Name: "search", Category: "CognitiveSearch", Target: "https://example"}, + }, + } + + got, err := promptResourceServices(t.Context(), client, agent, dir) + require.NoError(t, err) + + require.Len(t, got.Connections, 1) + assert.Equal(t, "search", got.Connections[0].Name) + assert.Equal(t, "CognitiveSearch", got.Connections[0].Category) + + // archive: points at the bundle folder so the whole bundle -- scripts and + // references included -- travels with the instructions. + require.Len(t, got.Skills, 1) + skill, ok := got.Skills["code-review"] + require.True(t, ok, "the skill is keyed by the name SKILL.md declares") + assert.Equal(t, "reviews code", skill.Description) + assert.Equal(t, "./"+path.Join(filepath.ToSlash(dir), "skills/code-review"), skill.Archive) + }) + + t.Run("toolbox reference is only wired when the service exists", func(t *testing.T) { + t.Parallel() + + agent := &agent_yaml.PromptAgent{Toolbox: &agent_yaml.ToolboxReference{Name: "my-toolbox"}} + + // A dangling uses: entry would fail the project load, so a toolbox with + // no service in azure.yaml contributes no edge at all. + client := newProjectRecorderClient(t, &recordingProjectServer{}) + got, err := promptResourceServices(t.Context(), client, agent, t.TempDir()) + require.NoError(t, err) + assert.Empty(t, got.ExtraUses) + + withToolbox := newProjectRecorderClient(t, &recordingProjectServer{ + existing: map[string]*azdext.ServiceConfig{ + "my-toolbox": {Name: "my-toolbox", Host: AiToolboxHost}, + }, + }) + got, err = promptResourceServices(t.Context(), withToolbox, agent, t.TempDir()) + require.NoError(t, err) + assert.Equal(t, []string{"my-toolbox"}, got.ExtraUses) + }) +} + +// TestEmitResourceServices_EmitsSkillServices verifies each skill bundle is +// written as its own azure.ai.skill service pointing at the bundle folder, and +// that the agent uses: it. Creating and versioning the skill belongs to the +// azure.ai.skills extension; the agents extension only attaches the version it +// publishes, so without this service nothing ever creates the skill. +func TestEmitResourceServices_EmitsSkillServices(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + + _, err := emitResourceServices(t.Context(), client, "myagent", "", foundryResources{ + Skills: map[string]project.SkillService{ + "code-review": {Description: "reviews code", Archive: "./skills/code-review"}, + }, + }) + require.NoError(t, err) + + server.mu.Lock() + defer server.mu.Unlock() + + var skillSvc *azdext.ServiceConfig + for _, svc := range server.added { + if svc.Host == AiSkillHost { + skillSvc = svc + } + } + require.NotNil(t, skillSvc, "a skill bundle must produce an azure.ai.skill service") + // The service key is the skill name; the skills extension has no name field. + assert.Equal(t, "code-review", skillSvc.Name) + require.NotNil(t, skillSvc.AdditionalProperties) + assert.Equal(t, + "./skills/code-review", + skillSvc.AdditionalProperties.Fields["archive"].GetStringValue(), + ) + // The skill must deploy before the agent that pins its version. + assert.Contains(t, server.uses["myagent"], "code-review") + assert.Equal(t, []string{aiProjectServiceName}, server.uses["code-review"]) +} + +// TestEmitResourceServices_ExtraUsesAreWired verifies a name passed as ExtraUses +// joins the agent's uses: without a service being emitted for it. A prompt +// agent's toolbox: references a toolbox someone else defines, so there is +// nothing to write, but the ordering edge is still required. +func TestEmitResourceServices_ExtraUsesAreWired(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + + _, err := emitResourceServices(t.Context(), client, "myagent", "", foundryResources{ + ExtraUses: []string{"my-toolbox"}, + }) + require.NoError(t, err) + + server.mu.Lock() + defer server.mu.Unlock() + + assert.Equal(t, []string{aiProjectServiceName, "my-toolbox"}, server.uses["myagent"]) + for _, svc := range server.added { + assert.NotEqual(t, "my-toolbox", svc.Name, "ExtraUses must not emit a service") + } +} + // TestEmitResourceServices_WiresSiblingsToProject verifies a connection service // is emitted alongside the project service, depends on it via uses: so the // project provisions first, and that the agent is wired to both siblings. @@ -696,7 +824,7 @@ func TestEmitResourceServices_WiresSiblingsToProject(t *testing.T) { client := newProjectRecorderClient(t, server) conns := []project.Connection{{Name: "myconn", Category: "ApiKey"}} - _, err := emitResourceServices(t.Context(), client, "myagent", "", nil, conns, nil) + _, err := emitResourceServices(t.Context(), client, "myagent", "", foundryResources{Connections: conns}) require.NoError(t, err) server.mu.Lock() @@ -721,7 +849,7 @@ func TestEmitResourceServices_CountsEmittedConnections(t *testing.T) { conns := []project.Connection{{Name: "myconn", Category: "ApiKey"}} got, err := emitResourceServices( - t.Context(), client, "myagent", "", nil, conns, nil) + t.Context(), client, "myagent", "", foundryResources{Connections: conns}) require.NoError(t, err) assert.Equal(t, 1, got) }) @@ -732,7 +860,7 @@ func TestEmitResourceServices_CountsEmittedConnections(t *testing.T) { conns := []project.Connection{{Name: " ", Category: "ApiKey"}} got, err := emitResourceServices( - t.Context(), client, "myagent", "", nil, conns, nil) + t.Context(), client, "myagent", "", foundryResources{Connections: conns}) require.NoError(t, err) assert.Equal(t, 0, got) @@ -762,7 +890,7 @@ func TestEmitResourceServices_WritesServiceLevelProps(t *testing.T) { }} conns := []project.Connection{{Name: "myconn", Category: "ApiKey", Target: "https://example", AuthType: "ApiKey"}} _, err := emitResourceServices( - t.Context(), client, "myagent", "", deployments, conns, nil) + t.Context(), client, "myagent", "", foundryResources{Deployments: deployments, Connections: conns}) require.NoError(t, err) server.mu.Lock() @@ -806,7 +934,7 @@ func TestEmitResourceServices_WritesEndpointForExistingProject(t *testing.T) { client := newProjectRecorderClient(t, server) _, err := emitResourceServices( - t.Context(), client, "myagent", projectEndpointRef, nil, nil, nil) + t.Context(), client, "myagent", projectEndpointRef, foundryResources{}) require.NoError(t, err) server.mu.Lock() @@ -826,7 +954,7 @@ func TestEmitResourceServices_WritesEndpointForExistingProject(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - _, err := emitResourceServices(t.Context(), client, "myagent", "", nil, nil, nil) + _, err := emitResourceServices(t.Context(), client, "myagent", "", foundryResources{}) require.NoError(t, err) server.mu.Lock() @@ -852,7 +980,7 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - _, err := emitResourceServices(t.Context(), client, "myagent", "", nil, nil, nil) + _, err := emitResourceServices(t.Context(), client, "myagent", "", foundryResources{}) require.NoError(t, err) server.mu.Lock() @@ -872,7 +1000,7 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { // The existing key wins so a repeated init does not create a second // project service. - _, err := emitResourceServices(t.Context(), client, "myagent", "", nil, nil, nil) + _, err := emitResourceServices(t.Context(), client, "myagent", "", foundryResources{}) require.NoError(t, err) server.mu.Lock() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go index 367f77b884b..489dbf1754e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go @@ -402,15 +402,15 @@ func TestResolveNextStepFromStatus_NonActiveBranches(t *testing.T) { } func TestDisplayHarness(t *testing.T) { - assert.Equal(t, "GitHub Copilot (github-copilot)", displayHarness("github-copilot")) + assert.Equal(t, "GitHub Copilot (github_copilot_preview)", displayHarness("github_copilot_preview")) assert.Equal(t, "custom-harness", displayHarness("custom-harness")) } // TestHarnessTypeFromMap covers both shapes `show` can be handed: agents // created before the harness became a block still carry a bare string. func TestHarnessTypeFromMap(t *testing.T) { - assert.Equal(t, "github-copilot", harnessTypeFromMap(map[string]any{ - "harness": map[string]any{"type": "github-copilot"}, + assert.Equal(t, "github_copilot_preview", harnessTypeFromMap(map[string]any{ + "harness": map[string]any{"type": "github_copilot_preview"}, })) assert.Equal(t, "ghcp", harnessTypeFromMap(map[string]any{"harness": "ghcp"})) assert.Equal(t, "", harnessTypeFromMap(map[string]any{"harness": map[string]any{}})) @@ -419,9 +419,9 @@ func TestHarnessTypeFromMap(t *testing.T) { func TestPromptDefinitionMap(t *testing.T) { version := agent_api.AgentVersionObject{ - Definition: map[string]any{"harness": "github-copilot"}, + Definition: map[string]any{"harness": "github_copilot_preview"}, } - assert.Equal(t, "github-copilot", stringFromMap(promptDefinitionMap(version), "harness")) + assert.Equal(t, "github_copilot_preview", stringFromMap(promptDefinitionMap(version), "harness")) // Non-map definition yields nil, and stringFromMap tolerates nil. assert.Nil(t, promptDefinitionMap(agent_api.AgentVersionObject{Definition: "not-a-map"})) diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index e6347d92b93..328f3352c78 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -66,6 +66,7 @@ const ( CodeMissingProjectEndpoint = "missing_project_endpoint" CodeGitHubDownloadFailed = "github_download_failed" CodePromptFailed = "prompt_failed" + CodeRaiPolicyNotFound = "rai_policy_not_found" ) // Error codes for ACR dependency errors. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations_test.go index 635e4a6f305..3ea345be772 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations_test.go @@ -188,7 +188,7 @@ func TestManagedAgentClient_CreateAgent_URLAndBody(t *testing.T) { Name: "my-managed", CreateAgentVersionRequest: CreateAgentVersionRequest{ Definition: ManagedAgentDefinition{ - AgentDefinition: AgentDefinition{Kind: AgentKindManaged}, + AgentDefinition: AgentDefinition{Kind: AgentKindPrompt}, Model: "gpt-4.1-mini", Instructions: "Be helpful.", }, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index e3059c5a0c3..a580920f428 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -68,10 +68,11 @@ type AgentKind string const ( AgentKindHosted AgentKind = "hosted" AgentKindWorkflow AgentKind = "workflow" - // AgentKindManaged is the Foundry "managed" / "prompt" agent kind backed - // by the Prompt Execution Service (PES). The API control plane accepts the - // value "prompt" as the wire discriminator for this kind. - AgentKindManaged AgentKind = "prompt" + // AgentKindPrompt is the Foundry prompt agent kind backed by the Prompt + // Execution Service (PES). "prompt" is the wire discriminator for both the + // plain and the harnessed ("managed") flavor — a harness is a property of + // the agent, not a kind of its own. + AgentKindPrompt AgentKind = "prompt" // AgentKindVoice is the data-plane (service) kind for a declarative voice // (speech-to-speech) agent. The azd manifest authoring kind is "prompt-voice" // (agent_yaml.AgentKindPromptVoice), which the map layer translates to this @@ -372,11 +373,10 @@ type ManagedEnvironment struct { // agent definition's `harness.type` field to run the agent on the GitHub // Copilot harness. // -// The managed-agent spec writes the wire discriminator as -// "github_copilot_preview". azd deliberately keeps the preview-free spelling -// until the service confirms the versioned one, so that a manifest does not -// have to be rewritten twice. -const ManagedAgentHarnessGitHubCopilot = "github-copilot" +// This is the spelling the managed-agent spec defines, and the `_preview` +// suffix is part of it: the harness is in preview and the service will version +// the discriminator when it leaves preview. +const ManagedAgentHarnessGitHubCopilot = "github_copilot_preview" // RemovedManagedAgentHarnesses maps a harness spelling the service no longer // accepts to the spelling that replaced it. @@ -384,7 +384,8 @@ const ManagedAgentHarnessGitHubCopilot = "github-copilot" // These are retained only so validation can name the replacement when it meets // an old manifest; none of them is ever sent on the wire or accepted as input. var RemovedManagedAgentHarnesses = map[string]string{ - "ghcp": ManagedAgentHarnessGitHubCopilot, + "ghcp": ManagedAgentHarnessGitHubCopilot, + "github-copilot": ManagedAgentHarnessGitHubCopilot, } // HarnessSkillReference pins one published Foundry skill onto a harnessed diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index 83e1fde5baf..cdf7979fe36 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -627,7 +627,7 @@ func mapHarnessBuiltInTools(builtin *PromptHarnessBuiltInTools) *agent_api.Harne // // The agent's Harness is omitted entirely when nil: a harness-less prompt // agent is run directly by Foundry, while a managed agent names its harness -// (e.g. "github-copilot") and the platform provisions a Brain+Hand +// (e.g. "github_copilot_preview") and the platform provisions a Brain+Hand // sandbox for it. func CreatePromptAgentAPIRequest( promptAgent PromptAgent, @@ -663,7 +663,7 @@ func CreatePromptAgentAPIRequest( promptDef := agent_api.ManagedAgentDefinition{ AgentDefinition: agent_api.AgentDefinition{ - Kind: agent_api.AgentKindManaged, + Kind: agent_api.AgentKindPrompt, RaiConfig: mapRaiConfig(promptAgent.Policies), }, Model: promptAgent.Model, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go index 8efa7d28551..855f78450d2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go @@ -65,7 +65,7 @@ var knowledgeToolTypes = map[string]bool{ // harnessedPromptFeatures records whether each capability is honored by a // *harnessed* prompt agent — a managed agent that names a harness such as -// "github-copilot" and runs in a platform-provisioned sandbox. +// "github_copilot_preview" and runs in a platform-provisioned sandbox. // // This map is the switch, and it follows the harness spec literally: a // capability is enabled only where the spec says the harness honors it. @@ -171,11 +171,23 @@ const ( // reads like a missing resource and sends authors hunting for a policy that is // in fact present on their account. The real cause is the shape of the value, // so name that instead of letting the deploy fail on a misleading message. +// +// A value that still carries an unexpanded ${VAR} reference is passed over +// rather than judged. `azd ai agent init` deliberately writes ${RAI_POLICY_ID} +// instead of the resource ID so a project can be copied to another +// subscription unchanged, and the concrete ID is substituted from the azd +// environment at deploy time. This function also runs when the manifest is +// first read, before that substitution has happened, where the eventual shape +// is not knowable. The expanded value is re-validated on the deploy path, so +// deferring here does not let a malformed ID through. func ValidateRaiPolicyName(name string) error { trimmed := strings.TrimSpace(name) if trimmed == "" { return nil } + if strings.Contains(trimmed, "${") { + return nil + } if strings.HasPrefix(trimmed, raiPolicyIDPrefix) && strings.Contains(trimmed, raiPolicyIDSegment) { return nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go index 32d97b10d30..d4de12f5ad9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go @@ -148,11 +148,18 @@ func TestPromptAgent_ValidateHarness(t *testing.T) { }{ {name: "absent harness is a plain prompt agent", harness: ""}, {name: "whitespace is treated as absent", harness: " "}, - {name: "current spelling is accepted", harness: "github-copilot"}, + {name: "current spelling is accepted", harness: "github_copilot_preview"}, { name: "abbreviated spelling names its replacement", harness: "ghcp", - wantErrPart: "github-copilot", + wantErrPart: "github_copilot_preview", + }, + { + // The pre-preview spelling azd used to write is now a removed + // value, so an older agent.yaml is told what to change it to. + name: "pre-preview spelling names its replacement", + harness: "github-copilot", + wantErrPart: "github_copilot_preview", }, { name: "unknown harness is left to the service", @@ -203,31 +210,31 @@ func TestValidateHarnessFeatures(t *testing.T) { }, { name: "harnessed agent without capabilities is fine", - harness: "github-copilot", + harness: "github_copilot_preview", agent: PromptAgent{}, }, { name: "harnessed agent accepts guardrails", - harness: "github-copilot", + harness: "github_copilot_preview", agent: PromptAgent{ Policies: []Policy{{Type: PolicyTypeRai, RaiPolicyName: testRaiPolicyID}}, }, }, { name: "harnessed agent rejects memory", - harness: "github-copilot", + harness: "github_copilot_preview", agent: PromptAgent{Memory: &PromptMemory{Store: "s"}}, wantRejected: []PromptFeature{PromptFeatureMemory}, }, { name: "harnessed agent rejects knowledge", - harness: "github-copilot", + harness: "github_copilot_preview", agent: PromptAgent{Tools: []any{map[string]any{"type": "file_search"}}}, wantRejected: []PromptFeature{PromptFeatureKnowledge}, }, { name: "harnessed agent reports memory and knowledge together", - harness: "github-copilot", + harness: "github_copilot_preview", agent: fullyFeatured, wantRejected: []PromptFeature{PromptFeatureMemory, PromptFeatureKnowledge}, }, @@ -275,10 +282,10 @@ func TestUnsupportedHarnessFeatures_ReportingOrder(t *testing.T) { // A harness-less agent is never gated, whatever the switch says. require.NoError(t, agent.ValidateHarnessFeatures()) - agent.Harness = NewPromptHarness("github-copilot") + agent.Harness = NewPromptHarness("github_copilot_preview") err := agent.ValidateHarnessFeatures() require.Error(t, err) - require.Contains(t, err.Error(), "github-copilot") + require.Contains(t, err.Error(), "github_copilot_preview") names := make([]string, 0, 3) for _, feature := range agent.UnsupportedHarnessFeatures() { @@ -349,6 +356,10 @@ func TestValidateRaiPolicyName(t *testing.T) { {name: "full arm id", policy: testRaiPolicyID, wantErr: false}, {name: "short but well formed arm id", policy: "/subscriptions/s/raiPolicies/p", wantErr: false}, {name: "surrounding whitespace tolerated", policy: " " + testRaiPolicyID + " ", wantErr: false}, + // The scaffold writes ${RAI_POLICY_ID}; the concrete ID is substituted + // on the deploy path and re-validated there. + {name: "unexpanded reference is deferred", policy: "${RAI_POLICY_ID}", wantErr: false}, + {name: "reference embedded in a path is deferred", policy: "/subscriptions/${SUB}/x", wantErr: false}, {name: "bare built-in name", policy: "Microsoft.DefaultV2", wantErr: true}, {name: "bare custom name", policy: "strict", wantErr: true}, {name: "missing raiPolicies segment", policy: "/subscriptions/s/resourceGroups/rg", wantErr: true}, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index 6afb4419ca6..bda2aa1a61d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -120,13 +120,25 @@ const ( AuthTypeSAS AuthType = "SAS" ) +// AuthTypeEntra is the name authors reach for when they mean "no secret, use +// the caller's Entra identity", and is what azd's own documentation and +// scaffolding have used. The service has never accepted it: its discriminator +// for that mode is AAD. Sent verbatim it fails provisioning with a bad-request +// listing twenty-one auth types, none of which explains that Entra and AAD are +// the same thing. It is normalized rather than rejected because it names the +// right concept. +const AuthTypeEntra AuthType = "Entra" + // NormalizeConnectionAuthType maps auth types accepted in agent.yaml to // the management-plane value required for project connection provisioning. // Legacy AgenticIdentity values are normalized to AgenticIdentityToken -// for API compatibility. +// for API compatibility, and Entra to AAD. func NormalizeConnectionAuthType(authType AuthType) AuthType { - if authType == AuthTypeAgenticIdentity { + switch authType { + case AuthTypeAgenticIdentity: return AuthTypeAgenticIdentityToken + case AuthTypeEntra: + return AuthTypeAAD } return authType @@ -387,7 +399,7 @@ type HarnessSkillRef struct { // else is equivalent to the old `harness: ` string. type PromptHarness struct { // Type is the harness discriminator, e.g. - // agent_api.ManagedAgentHarnessGitHubCopilot ("github-copilot"). + // agent_api.ManagedAgentHarnessGitHubCopilot ("github_copilot_preview"). // It is passed through verbatim: azd keeps no allowlist of harness names, so // a harness the service gains later needs no change here. Type string `json:"type" yaml:"type"` @@ -630,7 +642,10 @@ type PromptConnection struct { // deploy engine attempts to fill it from provisioning outputs. Target string `json:"target,omitempty" yaml:"target,omitempty"` - // AuthType selects the authentication mode ("Entra" default, or "ApiKey"). + // AuthType selects the authentication mode. Empty means AAD, the + // secret-free mode that uses the caller's Entra identity; "Entra" is + // accepted as a spelling of it and normalized. Otherwise one of the + // AuthType constants, e.g. "ApiKey". AuthType string `json:"authType,omitempty" yaml:"authType,omitempty"` // Credentials carries auth material for non-Entra auth (e.g. an API key, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go index 56d74f6558d..70f665ca904 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go @@ -232,4 +232,14 @@ func TestNormalizeConnectionAuthType(t *testing.T) { if got := NormalizeConnectionAuthType(AuthTypeOAuth2); got != AuthTypeOAuth2 { t.Fatalf("NormalizeConnectionAuthType(OAuth2) = %q, want %q", got, AuthTypeOAuth2) } + + // "Entra" is the name authors reach for; the service's discriminator for + // that mode is AAD, and sending it verbatim fails provisioning. + if got := NormalizeConnectionAuthType(AuthTypeEntra); got != AuthTypeAAD { + t.Fatalf("NormalizeConnectionAuthType(Entra) = %q, want %q", got, AuthTypeAAD) + } + + if got := NormalizeConnectionAuthType(AuthTypeAAD); got != AuthTypeAAD { + t.Fatalf("NormalizeConnectionAuthType(AAD) = %q, want %q", got, AuthTypeAAD) + } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_rai_policies.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_rai_policies.go new file mode 100644 index 00000000000..eacdbcb9c94 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_rai_policies.go @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azure + +import ( + "context" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + armcognitiveservices "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" +) + +// raiPolicyIDTemplate is the ARM resource ID shape the agent API requires for +// `rai_config.rai_policy_name`. The service rejects a bare policy name, so azd +// always writes and compares the full ID. +const raiPolicyIDTemplate = "/subscriptions/%s/resourceGroups/%s/providers/" + + "Microsoft.CognitiveServices/accounts/%s/raiPolicies/%s" + +// RaiPolicyInfo describes one Responsible AI policy on a Foundry (Cognitive +// Services) account. +type RaiPolicyInfo struct { + Name string + // ResourceID is the full ARM ID, which is the form the agent API accepts. + ResourceID string + // BasePolicyName is the policy this one derives from, e.g. + // "Microsoft.DefaultV2". Empty when the service does not report one. + BasePolicyName string + // SystemManaged is true for the service-supplied defaults every account + // carries. They are attachable but cannot be edited, so init presents them + // separately from the policies a user authored. + SystemManaged bool +} + +// RaiPolicyRef is a RAI policy's ARM resource ID decomposed into the parts the +// control-plane client needs. +type RaiPolicyRef struct { + SubscriptionID string + ResourceGroup string + AccountName string + PolicyName string +} + +// RaiPolicyResourceID builds the full ARM resource ID for a policy. +func RaiPolicyResourceID(subscriptionID, resourceGroup, accountName, policyName string) string { + return fmt.Sprintf( + raiPolicyIDTemplate, + strings.TrimSpace(subscriptionID), + strings.TrimSpace(resourceGroup), + strings.TrimSpace(accountName), + strings.TrimSpace(policyName), + ) +} + +// ParseRaiPolicyResourceID decomposes a RAI policy ARM resource ID. It reports +// false for any value that is not a well-formed policy ID, including a bare +// policy name and an ID that still contains an unexpanded ${VAR} reference. +// +// Segment names are matched case-insensitively because ARM echoes resource IDs +// back with the casing the caller used, and portal-copied IDs vary. +func ParseRaiPolicyResourceID(id string) (RaiPolicyRef, bool) { + parts := strings.Split(strings.Trim(strings.TrimSpace(id), "/"), "/") + if len(parts) != 10 { + return RaiPolicyRef{}, false + } + expected := map[int]string{ + 0: "subscriptions", + 2: "resourcegroups", + 4: "providers", + 5: "microsoft.cognitiveservices", + 6: "accounts", + 8: "raipolicies", + } + for i, want := range expected { + if !strings.EqualFold(parts[i], want) { + return RaiPolicyRef{}, false + } + } + ref := RaiPolicyRef{ + SubscriptionID: parts[1], + ResourceGroup: parts[3], + AccountName: parts[7], + PolicyName: parts[9], + } + if ref.SubscriptionID == "" || ref.ResourceGroup == "" || ref.AccountName == "" || ref.PolicyName == "" { + return RaiPolicyRef{}, false + } + return ref, true +} + +// ListRaiPolicies returns every RAI policy on a Foundry account, including the +// service-supplied defaults. +func ListRaiPolicies( + ctx context.Context, + credential azcore.TokenCredential, + subscriptionID, resourceGroup, accountName string, +) ([]RaiPolicyInfo, error) { + client, err := armcognitiveservices.NewRaiPoliciesClient(subscriptionID, credential, NewArmClientOptions()) + if err != nil { + return nil, fmt.Errorf("creating RAI policies client: %w", err) + } + + pager := client.NewListPager(resourceGroup, accountName, nil) + var results []RaiPolicyInfo + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("listing RAI policies on account %q: %w", accountName, err) + } + for _, policy := range page.Value { + if policy == nil || policy.Name == nil || *policy.Name == "" { + continue + } + info := RaiPolicyInfo{ + Name: *policy.Name, + ResourceID: RaiPolicyResourceID(subscriptionID, resourceGroup, accountName, *policy.Name), + } + // Prefer the ID the service reports; it is authoritative for casing. + if policy.ID != nil && *policy.ID != "" { + info.ResourceID = *policy.ID + } + if policy.Properties != nil { + if policy.Properties.BasePolicyName != nil { + info.BasePolicyName = *policy.Properties.BasePolicyName + } + if policy.Properties.Type != nil { + info.SystemManaged = *policy.Properties.Type == armcognitiveservices.RaiPolicyTypeSystemManaged + } + } + results = append(results, info) + } + } + return results, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_rai_policies_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_rai_policies_test.go new file mode 100644 index 00000000000..ff65b290364 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_rai_policies_test.go @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azure + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRaiPolicyResourceID verifies the ID is assembled in the form the agent +// API accepts. +func TestRaiPolicyResourceID(t *testing.T) { + t.Parallel() + + got := RaiPolicyResourceID("sub-1", "my-rg", "my-account", "my-policy") + require.Equal(t, + "/subscriptions/sub-1/resourceGroups/my-rg/providers/"+ + "Microsoft.CognitiveServices/accounts/my-account/raiPolicies/my-policy", + got, + ) +} + +// TestParseRaiPolicyResourceID covers the values a developer can end up with in +// agent.yaml: a real ID, a bare policy name, an unexpanded ${VAR} reference, +// and IDs that point at something other than a RAI policy. +func TestParseRaiPolicyResourceID(t *testing.T) { + t.Parallel() + + valid := "/subscriptions/sub-1/resourceGroups/my-rg/providers/" + + "Microsoft.CognitiveServices/accounts/my-account/raiPolicies/my-policy" + + tests := []struct { + name string + id string + ok bool + want RaiPolicyRef + }{ + { + name: "full resource id", + id: valid, + ok: true, + want: RaiPolicyRef{ + SubscriptionID: "sub-1", ResourceGroup: "my-rg", + AccountName: "my-account", PolicyName: "my-policy", + }, + }, + { + // ARM path segments are not case sensitive and the portal, the CLI + // and the SDK each spell them differently. + name: "mixed case segments", + id: "/SUBSCRIPTIONS/sub-1/RESOURCEGROUPS/my-rg/PROVIDERS/" + + "microsoft.cognitiveservices/ACCOUNTS/my-account/RAIPOLICIES/my-policy", + ok: true, + want: RaiPolicyRef{ + SubscriptionID: "sub-1", ResourceGroup: "my-rg", + AccountName: "my-account", PolicyName: "my-policy", + }, + }, + {name: "bare policy name", id: "Microsoft.DefaultV2"}, + {name: "unexpanded reference", id: "${RAI_POLICY_ID}"}, + {name: "empty", id: ""}, + { + name: "account id without policy", + id: "/subscriptions/sub-1/resourceGroups/my-rg/providers/" + + "Microsoft.CognitiveServices/accounts/my-account", + }, + { + name: "wrong resource type", + id: "/subscriptions/sub-1/resourceGroups/my-rg/providers/" + + "Microsoft.CognitiveServices/accounts/my-account/deployments/my-deployment", + }, + { + name: "wrong provider", + id: "/subscriptions/sub-1/resourceGroups/my-rg/providers/" + + "Microsoft.Storage/accounts/my-account/raiPolicies/my-policy", + }, + { + name: "empty segment", + id: "/subscriptions//resourceGroups/my-rg/providers/" + + "Microsoft.CognitiveServices/accounts/my-account/raiPolicies/my-policy", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, ok := ParseRaiPolicyResourceID(test.id) + require.Equal(t, test.ok, ok) + require.Equal(t, test.want, got) + }) + } +} + +// TestParseRaiPolicyResourceIDRoundTrip verifies the two helpers agree, so an +// ID built by init is always recognized by the deploy-time verification. +func TestParseRaiPolicyResourceIDRoundTrip(t *testing.T) { + t.Parallel() + + ref := RaiPolicyRef{ + SubscriptionID: "sub-1", ResourceGroup: "my-rg", + AccountName: "my-account", PolicyName: "my-policy", + } + + parsed, ok := ParseRaiPolicyResourceID( + RaiPolicyResourceID(ref.SubscriptionID, ref.ResourceGroup, ref.AccountName, ref.PolicyName), + ) + require.True(t, ok) + require.Equal(t, ref, parsed) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go deleted file mode 100644 index bdf7936fc52..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client.go +++ /dev/null @@ -1,267 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package azure - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/url" - "sort" - "strings" - - "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" - "github.com/azure/azure-dev/cli/azd/pkg/azsdk" - - "azureaiagent/internal/version" -) - -const ( - skillsApiVersion = "v1" - skillsFeatureHeader = "Skills=V1Preview" -) - -// FoundrySkillsClient registers Agent-Skills bundles with the Foundry skill -// data-plane so they can be referenced from a toolbox version. It is the -// primary (registration) path for turning a local skills/ folder into -// toolbox-attached skills. -type FoundrySkillsClient struct { - endpoint string - pipeline runtime.Pipeline -} - -// NewFoundrySkillsClient creates a client rooted at a Foundry project endpoint. -func NewFoundrySkillsClient(endpoint string, cred azcore.TokenCredential) *FoundrySkillsClient { - userAgent := fmt.Sprintf("azd-ext-azure-ai-agents/%s", version.Version) - - clientOptions := &policy.ClientOptions{ - Logging: policy.LogOptions{ - AllowedHeaders: []string{azsdk.MsCorrelationIdHeader, "X-Request-Id"}, - }, - PerCallPolicies: []policy.Policy{ - runtime.NewBearerTokenPolicy(cred, []string{"https://ai.azure.com/.default"}, nil), - azsdk.NewMsCorrelationPolicy(), - azsdk.NewUserAgentPolicy(userAgent), - }, - } - - pipeline := runtime.NewPipeline( - "azure-ai-agents", - "v1.0.0", - runtime.PipelineOptions{}, - clientOptions, - ) - - return &FoundrySkillsClient{ - endpoint: strings.TrimRight(endpoint, "/"), - pipeline: pipeline, - } -} - -// SkillVersionObject is the response for a registered skill version. -type SkillVersionObject struct { - Id string `json:"id"` - SkillId string `json:"skill_id"` - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description"` - CreatedAt int64 `json:"created_at"` -} - -// SkillInlineContent carries the skill definition inline for the JSON create -// path. Description is the one-line summary; Instructions is the skill body -// (the Markdown under the SKILL.md frontmatter) injected into the agent. -type SkillInlineContent struct { - Description string `json:"description,omitempty"` - Instructions string `json:"instructions"` -} - -// CreateSkillVersionRequest is the body for registering a skill version via the -// JSON inline-content path. The skill name comes from the URL path; the version -// is assigned by the service. Multi-file bundles (references/, assets/) require -// the ZIP/multipart upload path instead. -type CreateSkillVersionRequest struct { - InlineContent SkillInlineContent `json:"inline_content"` -} - -// CreateSkillVersion registers (or updates) a skill at the given name and -// returns the created version. When the skill does not exist it is created. -func (c *FoundrySkillsClient) CreateSkillVersion( - ctx context.Context, - skillName string, - request *CreateSkillVersionRequest, -) (*SkillVersionObject, error) { - targetURL := fmt.Sprintf( - "%s/skills/%s/versions?api-version=%s", - c.endpoint, url.PathEscape(skillName), skillsApiVersion, - ) - - payload, err := json.Marshal(request) - if err != nil { - return nil, fmt.Errorf("marshaling request: %w", err) - } - - req, err := runtime.NewRequest(ctx, http.MethodPost, targetURL) - if err != nil { - return nil, fmt.Errorf("creating request: %w", err) - } - req.Raw().Header.Set("Foundry-Features", skillsFeatureHeader) - if err := req.SetBody( - streaming.NopCloser(bytes.NewReader(payload)), - "application/json", - ); err != nil { - return nil, fmt.Errorf("setting request body: %w", err) - } - - resp, err := c.pipeline.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { - return nil, runtime.NewResponseError(resp) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading response body: %w", err) - } - var result SkillVersionObject - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("parsing response: %w", err) - } - return &result, nil -} - -// CreateSkillVersionFromFiles registers a skill version by uploading every -// file in a skill bundle — SKILL.md plus any references/, assets/, or other -// supporting files — via multipart/form-data. Unlike CreateSkillVersion (the -// JSON inline_content path, which only ever carries the SKILL.md body), this -// uploads the bundle's exact files: the service parses SKILL.md itself and -// stores every other file so the skill can reference them at runtime. Use -// this whenever a skill bundle contains more than a bare SKILL.md. -// -// files maps a bundle-relative path (forward-slash separated, e.g. -// "references/tone.md") to its raw content. -// -// POST {endpoint}/skills/{name}/versions?api-version=v1 (multipart/form-data) -func (c *FoundrySkillsClient) CreateSkillVersionFromFiles( - ctx context.Context, - skillName string, - files map[string][]byte, -) (*SkillVersionObject, error) { - if len(files) == 0 { - return nil, fmt.Errorf("no files to upload for skill %q", skillName) - } - - payload := &bytes.Buffer{} - writer := multipart.NewWriter(payload) - - // Sort for a deterministic request body (easier to test/debug/replay). - names := make([]string, 0, len(files)) - for name := range files { - names = append(names, name) - } - sort.Strings(names) - - for _, name := range names { - part, err := writer.CreateFormFile("files", name) - if err != nil { - return nil, fmt.Errorf("creating form file %q: %w", name, err) - } - if _, err := part.Write(files[name]); err != nil { - return nil, fmt.Errorf("writing file %q: %w", name, err) - } - } - if err := writer.Close(); err != nil { - return nil, fmt.Errorf("closing multipart writer: %w", err) - } - - targetURL := fmt.Sprintf( - "%s/skills/%s/versions?api-version=%s", - c.endpoint, url.PathEscape(skillName), skillsApiVersion, - ) - req, err := runtime.NewRequest(ctx, http.MethodPost, targetURL) - if err != nil { - return nil, fmt.Errorf("creating request: %w", err) - } - req.Raw().Header.Set("Foundry-Features", skillsFeatureHeader) - if err := req.SetBody( - streaming.NopCloser(bytes.NewReader(payload.Bytes())), - writer.FormDataContentType(), - ); err != nil { - return nil, fmt.Errorf("setting request body: %w", err) - } - - resp, err := c.pipeline.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { - return nil, runtime.NewResponseError(resp) - } - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading response body: %w", err) - } - var result SkillVersionObject - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("parsing response: %w", err) - } - return &result, nil -} - -// PromoteSkillVersion updates the skill's default_version, making it the -// version resolved by references that omit an explicit version (including the -// Foundry portal's skill view). Creating a skill version does NOT -// automatically promote it — every version after the first must be promoted -// explicitly for consumers to see it as the active content. -// -// POST {endpoint}/skills/{name}?api-version=v1 -func (c *FoundrySkillsClient) PromoteSkillVersion( - ctx context.Context, - skillName string, - version string, -) error { - targetURL := fmt.Sprintf( - "%s/skills/%s?api-version=%s", - c.endpoint, url.PathEscape(skillName), skillsApiVersion, - ) - - payload, err := json.Marshal(map[string]string{"default_version": version}) - if err != nil { - return fmt.Errorf("marshaling request: %w", err) - } - - req, err := runtime.NewRequest(ctx, http.MethodPost, targetURL) - if err != nil { - return fmt.Errorf("creating request: %w", err) - } - req.Raw().Header.Set("Foundry-Features", skillsFeatureHeader) - if err := req.SetBody( - streaming.NopCloser(bytes.NewReader(payload)), - "application/json", - ); err != nil { - return fmt.Errorf("setting request body: %w", err) - } - - resp, err := c.pipeline.Do(req) - if err != nil { - return fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - if !runtime.HasStatusCode(resp, http.StatusOK) { - return runtime.NewResponseError(resp) - } - return nil -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go deleted file mode 100644 index c1256ab56a6..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_skills_client_test.go +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package azure - -import ( - "io" - "net/http" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func newTestSkillsClient(endpoint string, fn roundTripFunc) *FoundrySkillsClient { - return &FoundrySkillsClient{ - endpoint: endpoint, - pipeline: newTestPipeline(fn), - } -} - -func TestCreateSkillVersion_RequestShape(t *testing.T) { - var captured *http.Request - var body []byte - - client := newTestSkillsClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { - captured = req - if req.Body != nil { - body, _ = io.ReadAll(req.Body) - } - return &http.Response{ - StatusCode: http.StatusCreated, - Body: io.NopCloser(strings.NewReader(`{"id":"s-1","name":"my-skill","version":"1.2.0"}`)), - Header: make(http.Header), - }, nil - }) - - out, err := client.CreateSkillVersion(t.Context(), "my skill", &CreateSkillVersionRequest{ - InlineContent: SkillInlineContent{ - Description: "does things", - Instructions: "You are a helpful skill.", - }, - }) - require.NoError(t, err) - require.Equal(t, "1.2.0", out.Version) - - require.NotNil(t, captured) - require.Equal(t, http.MethodPost, captured.Method) - require.Equal(t, "/skills/my%20skill/versions", captured.URL.EscapedPath()) - require.Equal(t, "api-version="+skillsApiVersion, captured.URL.RawQuery) - require.Equal(t, skillsFeatureHeader, captured.Header.Get("Foundry-Features")) - // inline_content is an object with description + instructions (no envelope). - require.Contains(t, string(body), `"inline_content"`) - require.Contains(t, string(body), `"instructions":"You are a helpful skill."`) - require.Contains(t, string(body), `"description":"does things"`) -} - -func TestCreateSkillVersion_ErrorStatus(t *testing.T) { - client := newTestSkillsClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusBadRequest, - Body: io.NopCloser(strings.NewReader(`{"error":"bad"}`)), - Header: make(http.Header), - }, nil - }) - _, err := client.CreateSkillVersion(t.Context(), "s", &CreateSkillVersionRequest{ - InlineContent: SkillInlineContent{Instructions: "x"}, - }) - require.Error(t, err) -} - -func TestCreateSkillVersionFromFiles_UploadsEveryFile(t *testing.T) { - var captured *http.Request - var body []byte - - client := newTestSkillsClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { - captured = req - if req.Body != nil { - body, _ = io.ReadAll(req.Body) - } - return &http.Response{ - StatusCode: http.StatusCreated, - Body: io.NopCloser(strings.NewReader(`{"id":"s-1","name":"my-skill","version":"1.0.0"}`)), - Header: make(http.Header), - }, nil - }) - - files := map[string][]byte{ - "SKILL.md": []byte("---\nname: my-skill\n---\nbody"), - "references/tone.md": []byte("tone guidance"), - "assets/logo.svg": []byte(""), - "scripts/analysis.py": []byte("print('hi')"), - } - - out, err := client.CreateSkillVersionFromFiles(t.Context(), "my-skill", files) - require.NoError(t, err) - require.Equal(t, "1.0.0", out.Version) - - require.NotNil(t, captured) - require.Equal(t, http.MethodPost, captured.Method) - require.Equal(t, "/skills/my-skill/versions", captured.URL.EscapedPath()) - require.Contains(t, captured.Header.Get("Content-Type"), "multipart/form-data") - require.Equal(t, skillsFeatureHeader, captured.Header.Get("Foundry-Features")) - - // Every file in the bundle — not just SKILL.md — must be present in the - // multipart body. This is the regression this test guards: uploading only - // SKILL.md silently drops references/, assets/, and any other bundle files. - bodyStr := string(body) - for name, content := range files { - require.Contains(t, bodyStr, name, "multipart body missing file part for %q", name) - require.Contains(t, bodyStr, string(content), "multipart body missing content for %q", name) - } -} - -func TestCreateSkillVersionFromFiles_EmptyFilesErrors(t *testing.T) { - client := newTestSkillsClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { - t.Fatal("no HTTP request should be made when files is empty") - return nil, nil - }) - _, err := client.CreateSkillVersionFromFiles(t.Context(), "s", map[string][]byte{}) - require.Error(t, err) -} - -func TestCreateSkillVersionFromFiles_ErrorStatus(t *testing.T) { - client := newTestSkillsClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusBadRequest, - Body: io.NopCloser(strings.NewReader(`{"error":"bad"}`)), - Header: make(http.Header), - }, nil - }) - _, err := client.CreateSkillVersionFromFiles(t.Context(), "s", map[string][]byte{"SKILL.md": []byte("x")}) - require.Error(t, err) -} - -func TestPromoteSkillVersion_RequestShape(t *testing.T) { - var captured *http.Request - var body []byte - - client := newTestSkillsClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { - captured = req - if req.Body != nil { - body, _ = io.ReadAll(req.Body) - } - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"id":"s-1","name":"my-skill","default_version":"1.2.0"}`)), - Header: make(http.Header), - }, nil - }) - - err := client.PromoteSkillVersion(t.Context(), "my-skill", "1.2.0") - require.NoError(t, err) - - require.NotNil(t, captured) - require.Equal(t, http.MethodPost, captured.Method) - require.Equal(t, "/skills/my-skill", captured.URL.EscapedPath()) - require.Equal(t, "api-version="+skillsApiVersion, captured.URL.RawQuery) - require.Equal(t, skillsFeatureHeader, captured.Header.Get("Foundry-Features")) - require.Contains(t, string(body), `"default_version":"1.2.0"`) -} - -func TestPromoteSkillVersion_ErrorStatus(t *testing.T) { - client := newTestSkillsClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusBadRequest, - Body: io.NopCloser(strings.NewReader(`{"error":"bad"}`)), - Header: make(http.Header), - }, nil - }) - err := client.PromoteSkillVersion(t.Context(), "s", "1.0.0") - require.Error(t, err) -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index 26b5a06f874..05f02f8aef2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -310,14 +310,22 @@ func environmentVariablesFromMap( // the marker that an agent definition is present in a service entry's inline or // config properties. func structHasKind(s *structpb.Struct) bool { + return structKind(s) != "" +} + +// structKind returns the string `kind` a service entry's properties carry, or +// "" when the field is absent or is not a string. Callers use it to pick the +// right definition shape before decoding, because the kinds share the entry's +// key space but not its types. +func structKind(s *structpb.Struct) string { if s == nil { - return false + return "" } v, ok := s.Fields["kind"] if !ok { - return false + return "" } - return v.GetStringValue() != "" + return v.GetStringValue() } // LoadAgentDefinition resolves the hosted-agent definition for an azure.ai.agent @@ -689,7 +697,12 @@ func InlineAgentEnvironmentVariables( if props == nil || len(props.GetFields()) == 0 { return nil, nil } - var inline AgentDefinitionInline + // Decode only the one deprecated field. The full inline shape is the hosted + // agent's, and a prompt or voice entry would fail to decode against it over + // fields this function never reads. + var inline struct { + EnvironmentVariables *[]agent_yaml.EnvironmentVariable `json:"environmentVariables,omitempty"` + } if err := UnmarshalStruct(props, &inline); err != nil { return nil, err } @@ -739,6 +752,18 @@ func agentDefinitionFromStruct( coreImage string, environment map[string]string, ) (agent_yaml.ContainerAgent, bool, error) { + // The kind gate has to come before the decode, not after it. Every agent + // kind lands in the same property bag but they do not agree on types: a + // prompt agent's `model` is a deployment name, while the hosted and voice + // shapes model it as an object. Decoding first would reject a perfectly + // valid prompt agent with a type error naming a field it does not have. + if structKind(s) != string(agent_yaml.AgentKindHosted) { + if err := validateAgentServiceDefinition(s.AsMap()); err != nil { + return agent_yaml.ContainerAgent{}, false, err + } + return agent_yaml.ContainerAgent{}, false, nil + } + var inline AgentDefinitionInline if err := UnmarshalStruct(s, &inline); err != nil { return agent_yaml.ContainerAgent{}, false, exterrors.Validation( @@ -748,13 +773,6 @@ func agentDefinitionFromStruct( ) } - if inline.Kind != agent_yaml.AgentKindHosted { - if err := validateAgentServiceDefinition(s.AsMap()); err != nil { - return agent_yaml.ContainerAgent{}, false, err - } - return agent_yaml.ContainerAgent{}, false, nil - } - var cfg ServiceTargetAgentConfig if err := UnmarshalStruct(s, &cfg); err != nil { return agent_yaml.ContainerAgent{}, false, exterrors.Validation( diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go index 629dd12a41f..77a78139fcd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -217,6 +217,36 @@ func TestInlineAgentEnvironmentVariables(t *testing.T) { "SHARED_KEY": "legacy", }, got) } + +// TestAgentDefinitionFromService_PromptAgentStringModel guards the kind gate in +// agentDefinitionFromStruct. Every agent kind shares the service entry's +// property bag but they disagree on types: a prompt agent's `model` is a +// deployment name, while the hosted and voice shapes model it as an object. +// Decoding before checking the kind rejected a valid prompt agent with +// "cannot unmarshal string into Go struct field AgentDefinitionInline.model". +func TestAgentDefinitionFromService_PromptAgentStringModel(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "kind": "prompt", + "name": "my-prompt-agent", + "model": "gpt-4.1-mini", + "instructions": "You are a helpful assistant.", + }) + require.NoError(t, err) + + svc := &azdext.ServiceConfig{ + Name: "my-prompt-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + + _, isHosted, found, _, err := AgentDefinitionFromService(svc) + require.NoError(t, err) + require.True(t, found) + require.False(t, isHosted) +} + func TestResolveAgentEnvironmentVariable(t *testing.T) { t.Parallel() @@ -859,13 +889,6 @@ func TestWarnOrphanedConfigEnvOutput(t *testing.T) { require.Empty(t, quiet) } -func mustStruct(t *testing.T, value map[string]any) *structpb.Struct { - t.Helper() - s, err := structpb.NewStruct(value) - require.NoError(t, err) - return s -} - // captureStdout collects everything fn writes to os.Stdout. func captureStdout(t *testing.T, fn func()) string { t.Helper() diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config.go b/cli/azd/extensions/azure.ai.agents/internal/project/config.go index 391f186745d..6397cf8f185 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -167,6 +167,27 @@ func (t *Toolbox) UnmarshalJSON(data []byte) error { return nil } +// SkillService is the azure.yaml service-level config for a `host: +// azure.ai.skill` entry, which the azure.ai.skills extension owns and deploys. +// Only the fields azd writes are modeled here; the extension's schema also +// accepts license, compatibility, metadata and tools, which authors may add by +// hand. +// +// The skill's name is the azure.yaml service key rather than a field, and its +// version is assigned by the service on each deploy and published back to the +// azd environment as SKILL__VERSION. +type SkillService struct { + // Description is the skill description, taken from the bundle's SKILL.md + // frontmatter so azure.yaml reads the same as the folder it points at. + Description string `json:"description,omitempty"` + + // Archive is the path, relative to azure.yaml, of the directory containing + // SKILL.md. A directory rather than the SKILL.md file itself, so the whole + // bundle -- scripts, references, assets -- is packaged with the + // instructions instead of only the Markdown body. + Archive string `json:"archive"` +} + // MemoryStore represents a Foundry memory store provisioned (create-if-not-exists) // during deployment. It backs the agent's memory_search tool, letting the agent retain // context across sessions. ChatModel and EmbeddingModel reference model deployment names diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go index d0260148930..85a897e7b03 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go @@ -998,6 +998,13 @@ func checkVocabulary(t *testing.T, e docExample, name string, svc map[string]any prop, ok := schema.property(key) require.True(t, ok, undeclaredPropertyMessage(e, name, key)) schema.checkValue(t, e, name, key, prop, value) + + // `$ref` points at the file carrying the definition; it is not part of + // the definition itself, so it neither makes the inline shape active nor + // conflicts with a deprecated config block. + if key == AgentDefinitionRefKey { + continue + } inline[key] = value } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go index 6249656fe85..c6301c7d3d8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go @@ -79,8 +79,11 @@ func validateFoundryDependencies( detail: detail, requiresProvision: host == foundryProjectHost || host == legacyFoundryHost || host == foundryConnectionHost, + // Toolboxes, agents, skills and routines are all applied during + // `azd deploy`, so a failure on any of them is fixed by deploying + // the dependency first rather than by re-provisioning. requiresDeploy: host == foundryToolboxHost || host == foundryAgentHost || - host == foundrySkillHost, + host == foundrySkillHost || host == foundryRoutineHost, }) } } @@ -227,6 +230,13 @@ func validateFoundryDependency( return validateFoundryAgentDependency(service, env) case foundrySkillHost: return validateFoundrySkillDependency(service, env) + case foundryRoutineHost: + // A routine names the agent it dispatches, so the dependency edge points + // from the routine to the agent, not the other way around. The host is + // listed here so a hand-authored `uses:` entry is recognized rather than + // falling through to the default; there is nothing to check because the + // routine extension publishes no readiness marker. + return "" default: return "" } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go index 25aa8932c9a..bf45b0cdf19 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go @@ -14,6 +14,7 @@ import ( "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/azure" + "azureaiagent/internal/pkg/envkey" "github.com/azure/azure-dev/cli/azd/pkg/output" ) @@ -129,6 +130,29 @@ func targetFromEnv(name string, env map[string]string) string { return "" } +// siblingOwnsConnection reports whether a sibling `host: azure.ai.connection` +// service provisioned a connection of this name into the project this agent +// targets. The azure.ai.connections extension records the names it provisioned +// in AZURE_AI_PROJECT_CONNECTION_NAMES, along with the project endpoint it +// provisioned them into; a name recorded against a different project is ignored +// so a reused environment cannot make azd skip creating a connection that is +// genuinely absent here. +func siblingOwnsConnection(name string, env map[string]string) bool { + if env == nil { + return false + } + declared := strings.TrimSpace(env[envkey.ConnectionProjectEndpoint]) + if declared != "" && !sameProjectEndpoint(declared, env["FOUNDRY_PROJECT_ENDPOINT"]) { + return false + } + for entry := range strings.SplitSeq(env["AZURE_AI_PROJECT_CONNECTION_NAMES"], ",") { + if strings.EqualFold(strings.TrimSpace(entry), strings.TrimSpace(name)) { + return true + } + } + return false +} + // resolveConnectionAction decides how to satisfy one declared connection given // the set of existing connection names and the azd environment. It is pure and // table-testable; the connection node performs the side effects. @@ -148,6 +172,14 @@ func resolveConnectionAction( ) } + // Rung 0: the connection has a sibling `host: azure.ai.connection` service + // that already provisioned it. That extension owns the connection, so azd + // takes it as-is rather than racing to create a second one -- the data-plane + // listing that rung 1 consults can lag a just-provisioned connection. + if siblingOwnsConnection(decl.Name, env) { + return connActionUseExisting, decl, nil + } + // Rung 1: an existing connection with this name is used as-is. if _, ok := existing[decl.Name]; ok { return connActionUseExisting, decl, nil @@ -347,9 +379,14 @@ func (r *foundryConnectionResolver) Create( return "", err } created, err := r.client.CreateConnection(ctx, decl.Name, &azure.CreateConnectionRequest{ - Category: decl.Category, - Target: decl.Target, - AuthType: decl.AuthType, // empty defaults to AAD in the client + Category: decl.Category, + Target: decl.Target, + // Empty defaults to AAD in the client. Non-empty is normalized so the + // authoring spelling "Entra" reaches the service as the AAD + // discriminator it actually accepts. + AuthType: string(agent_yaml.NormalizeConnectionAuthType( + agent_yaml.AuthType(decl.AuthType), + )), Credentials: credentials, Metadata: decl.Metadata, }) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go index 6c72c16786d..36c7443e933 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go @@ -8,8 +8,40 @@ import ( "testing" "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/envkey" ) +// TestResolveConnectionAction_Rung0_SiblingOwned covers the case where a +// sibling `host: azure.ai.connection` service provisioned the connection. The +// data-plane listing rung 1 consults can lag a just-provisioned connection, so +// without this rung azd would race that extension and try to create a second +// connection of the same name. +func TestResolveConnectionAction_Rung0_SiblingOwned(t *testing.T) { + decl := agent_yaml.PromptConnection{Name: "aisearch-conn", Category: "CognitiveSearch"} + env := map[string]string{ + "AZURE_AI_PROJECT_CONNECTION_NAMES": "other-conn,aisearch-conn", + envkey.ConnectionProjectEndpoint: "https://acct.services.ai.azure.com/api/projects/p", + "FOUNDRY_PROJECT_ENDPOINT": "https://acct.services.ai.azure.com/api/projects/p", + } + + // No existing connections and no target: without rung 0 this would fail fast. + action, _, err := resolveConnectionAction(decl, nil, env) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if action != connActionUseExisting { + t.Errorf("action: got %v, want use-existing", action) + } + + // A marker left over from a different project must not suppress creation -- + // the connection genuinely does not exist in the project being targeted. + env["FOUNDRY_PROJECT_ENDPOINT"] = "https://other.services.ai.azure.com/api/projects/q" + action, _, err = resolveConnectionAction(decl, nil, env) + if err == nil { + t.Fatalf("expected a stale marker to fall through to fail-fast, got %v", action) + } +} + func TestResolveConnectionAction_Rung1_ExistingByName(t *testing.T) { existing := map[string]string{"aisearch-conn": "id-1"} decl := agent_yaml.PromptConnection{Name: "aisearch-conn", Category: "CognitiveSearch"} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go index 82ea5bd03e0..7f94a567510 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go @@ -29,6 +29,7 @@ const ( nodeMemoryStore promptNodeKind = "memory_store" nodeSkill promptNodeKind = "skill" nodeToolbox promptNodeKind = "toolbox" + nodePolicy promptNodeKind = "policy" ) // promptNode is a single dependency in the prompt-agent deploy graph. Validate @@ -141,9 +142,12 @@ func newPromptGraph( } // Convention: a non-empty skills/ folder contributes the agent's skills. - // How they are reached splits on the harness — a managed agent provisions - // them into its sandbox by pinning them on the harness block, while a plain - // prompt agent references them by name and runs them with a shell tool. + // The bundles themselves are created and versioned by the sibling + // `host: azure.ai.skill` services that `azd ai agent init` emits; these + // nodes only attach the versions those services published. How they are + // reached splits on the harness — a managed agent provisions them into its + // sandbox by pinning them on the harness block, while a plain prompt agent + // references them by name and runs them with a shell tool. skills, err := scanSkillsDir(agentDir) if err != nil { return nil, err @@ -159,15 +163,11 @@ func newPromptGraph( }); node != nil { g.nodes = append(g.nodes, *node) } - if node := skillsHarnessNode(g, skills, func() (harnessSkillPublisher, error) { - return newFoundrySkillPublisher(settings) - }); node != nil { + if node := skillsHarnessNode(g, skills); node != nil { g.nodes = append(g.nodes, *node) } } else { - if node := skillsShellNode(g, skills, managed.Toolbox, func() (skillAttacher, error) { - return newFoundrySkillPublisher(settings) - }); node != nil { + if node := skillsShellNode(g, skills, managed.Toolbox); node != nil { g.nodes = append(g.nodes, *node) } } @@ -181,6 +181,14 @@ func newPromptGraph( g.nodes = append(g.nodes, *node) } + // Guardrails are checked just before the agent node so a policy that does + // not exist is reported by name instead of as an opaque service rejection + // from the create call, and is replaced with the account's built-in default + // rather than failing the deploy. + if node := policiesNode(g, azureRaiPolicyLister); node != nil { + g.nodes = append(g.nodes, *node) + } + // The agent node is terminal and validated last. g.nodes = append(g.nodes, g.agentNode()) return g, nil diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_policy_node.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_policy_node.go new file mode 100644 index 00000000000..f4312e80287 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_policy_node.go @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "cmp" + "context" + "fmt" + "slices" + "strings" + + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" +) + +// raiPolicyLister returns every Responsible AI policy on the account named by +// ref. Listing rather than probing one name at a time answers both of the +// node's questions from a single call: whether the declared policy is present, +// and what to fall back to when it is not. It exists as a function type so the +// node can be exercised without an Azure call. +type raiPolicyLister func(ctx context.Context, ref azure.RaiPolicyRef) ([]azure.RaiPolicyInfo, error) + +// policiesNode reconciles every declared Responsible AI policy against the +// account before the agent version is published. +// +// The create call reports a policy the account does not have as a generic bad +// request that names neither the policy nor the account, so a typo or a +// forgotten `azd provision` surfaces as an opaque service rejection at the very +// end of a deploy. Checking here names the policy and the account it was looked +// for on. +// +// A policy that is absent is a warning, not a failure. The declared name is the +// author's preference, not a safety floor: the account applies its own default +// content filters to an agent that names no policy at all, so falling back to +// the built-in default leaves the agent no less filtered than publishing it +// without guardrails would. Failing instead would block a deploy over a value +// that is trivially editable afterwards, and the substitution is reported so +// the author can point policies[].raiPolicyName somewhere else and redeploy. +// +// Returns nil when the agent declares no RAI policy, so the deploy path is +// unchanged for the agents that do not use one. +func policiesNode(g *promptGraph, newLister func() (raiPolicyLister, error)) *promptNode { + var declared []int + for i, policy := range g.managed.Policies { + if policy.Type != agent_yaml.PolicyTypeRai { + continue + } + if strings.TrimSpace(policy.RaiPolicyName) != "" { + declared = append(declared, i) + } + } + if len(declared) == 0 { + return nil + } + + names := make([]string, 0, len(declared)) + for _, i := range declared { + names = append(names, strings.TrimSpace(g.managed.Policies[i].RaiPolicyName)) + } + + return &promptNode{ + Kind: nodePolicy, + ID: strings.Join(names, ","), + // Shape validation already runs on the agent node through + // ValidatePolicies; nothing further is knowable without a live call. + Validate: func() error { return nil }, + Resolve: func(ctx context.Context) error { + lister, err := newLister() + if err != nil { + g.warnf("could not verify Responsible AI policies: %v", err) + return nil + } + var dropped []int + for _, i := range declared { + ref, ok := azure.ParseRaiPolicyResourceID(g.managed.Policies[i].RaiPolicyName) + if !ok { + // The agent node's ValidatePolicies already rejected this + // shape; guard rather than issue a nonsense request. + continue + } + existing, err := lister(ctx, ref) + if err != nil { + // A missing read permission must not block a deploy that + // would otherwise succeed: the service is still the + // authority on whether the policy is usable. + g.warnf( + "could not verify Responsible AI policy %q on account %q: %v", + ref.PolicyName, ref.AccountName, err, + ) + continue + } + if raiPolicyPresent(existing, ref.PolicyName) { + continue + } + + fallback, ok := defaultRaiPolicy(existing) + if !ok { + dropped = append(dropped, i) + g.warnf( + "Responsible AI policy %q was not found on Foundry account %q and the account "+ + "carries no built-in policy to fall back to; publishing without guardrails, "+ + "which leaves the account's default content filters in force. Point "+ + "policies[].raiPolicyName in agent.yaml at a policy that exists and redeploy.", + ref.PolicyName, ref.AccountName, + ) + continue + } + g.managed.Policies[i].RaiPolicyName = fallback.ResourceID + g.warnf( + "Responsible AI policy %q was not found on Foundry account %q; using the built-in "+ + "%q instead. Point policies[].raiPolicyName in agent.yaml at the policy you "+ + "want and redeploy, or create it with: az cognitiveservices account rai-policy "+ + "create --name %s --resource-group %s --rai-policy-name %s", + ref.PolicyName, ref.AccountName, fallback.Name, + ref.AccountName, ref.ResourceGroup, ref.PolicyName, + ) + } + if len(dropped) > 0 { + kept := make([]agent_yaml.Policy, 0, len(g.managed.Policies)) + for i, policy := range g.managed.Policies { + if slices.Contains(dropped, i) { + continue + } + kept = append(kept, policy) + } + g.managed.Policies = kept + } + return nil + }, + } +} + +// raiPolicyPresent reports whether the account carries a policy by this name. +// The comparison is case-insensitive because ARM echoes resource IDs back with +// the casing the caller used, so a hand-copied ID may disagree with the +// service's own casing without naming a different policy. +func raiPolicyPresent(policies []azure.RaiPolicyInfo, name string) bool { + return slices.ContainsFunc(policies, func(policy azure.RaiPolicyInfo) bool { + return strings.EqualFold(policy.Name, name) + }) +} + +// defaultRaiPolicy picks the policy to substitute when the declared one is not +// on the account. +// +// Only the service-supplied built-ins are eligible. Attaching a policy someone +// else authored would apply content filters nobody asked for and that azd +// cannot reason about, whereas the built-ins are the same filters the account +// already applies to an agent that names no policy. +func defaultRaiPolicy(policies []azure.RaiPolicyInfo) (azure.RaiPolicyInfo, bool) { + builtIn := make([]azure.RaiPolicyInfo, 0, len(policies)) + for _, policy := range policies { + if policy.SystemManaged { + builtIn = append(builtIn, policy) + } + } + if len(builtIn) == 0 { + return azure.RaiPolicyInfo{}, false + } + // Newest built-in first, so an account carrying both lands on the current + // defaults. Ties break by name so the choice does not vary with the order + // the service happened to return. + slices.SortFunc(builtIn, func(a, b azure.RaiPolicyInfo) int { + if rank := cmp.Compare(raiPolicyRank(a.Name), raiPolicyRank(b.Name)); rank != 0 { + return rank + } + return cmp.Compare(a.Name, b.Name) + }) + return builtIn[0], true +} + +// raiPolicyRank orders the service's built-in policies newest first. Anything +// unrecognized sorts last rather than being excluded, so an account whose +// built-ins are renamed still yields a fallback. +func raiPolicyRank(name string) int { + switch { + case strings.EqualFold(name, "Microsoft.DefaultV2"): + return 0 + case strings.EqualFold(name, "Microsoft.Default"): + return 1 + default: + return 2 + } +} + +// azureRaiPolicyLister lists the account's policies from ARM using the same +// credential the rest of the prompt deploy path uses. +func azureRaiPolicyLister() (raiPolicyLister, error) { + credential := promptCredential() + if credential == nil { + return nil, fmt.Errorf("no Azure credential is available") + } + return func(ctx context.Context, ref azure.RaiPolicyRef) ([]azure.RaiPolicyInfo, error) { + return azure.ListRaiPolicies(ctx, credential, ref.SubscriptionID, ref.ResourceGroup, ref.AccountName) + }, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_policy_node_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_policy_node_test.go new file mode 100644 index 00000000000..8bce88d9efd --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_policy_node_test.go @@ -0,0 +1,277 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// TestExpandPromptAgentPolicies verifies the ${RAI_POLICY_ID} indirection init +// writes resolves against the azd environment before anything validates the +// shape of the value. +func TestExpandPromptAgentPolicies(t *testing.T) { + t.Parallel() + + managed := agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: "${RAI_POLICY_ID}"}, + }, + } + + require.NoError(t, expandPromptAgentPolicies(&managed, map[string]string{ + "RAI_POLICY_ID": raiPolicyID, + })) + require.Equal(t, raiPolicyID, managed.Policies[0].RaiPolicyName) + + // The expanded value must satisfy the shape check that runs later, or the + // indirection would trade one confusing failure for another. + require.NoError(t, managed.ValidatePolicies()) +} + +// TestExpandPromptAgentPoliciesUnresolved verifies an unset variable fails +// loudly. Publishing the agent without the guardrails its manifest declares +// would be worse than not publishing at all. +func TestExpandPromptAgentPoliciesUnresolved(t *testing.T) { + t.Parallel() + + managed := agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: "${RAI_POLICY_ID}"}, + }, + } + + err := expandPromptAgentPolicies(&managed, map[string]string{}) + require.ErrorContains(t, err, "not set in the azd environment") + + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + require.Contains(t, localErr.Suggestion, "azd provision") + require.Contains(t, localErr.Suggestion, "RAI_POLICY_ID") +} + +// TestExpandPromptAgentPoliciesLeavesLiterals verifies a literal resource ID is +// untouched, so projects that already hard-code one keep working. +func TestExpandPromptAgentPoliciesLeavesLiterals(t *testing.T) { + t.Parallel() + + managed := agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + } + + require.NoError(t, expandPromptAgentPolicies(&managed, nil)) + require.Equal(t, raiPolicyID, managed.Policies[0].RaiPolicyName) +} + +// TestPromptAgentPoliciesReachRaiConfig is the prompt-agent counterpart to +// TestAgentPoliciesReachRaiConfig: a policy authored on a prompt or managed +// agent must arrive as rai_config.rai_policy_name on the managed definition. +func TestPromptAgentPoliciesReachRaiConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + harness *agent_yaml.PromptHarness + }{ + {name: "prompt agent"}, + { + name: "managed agent", + harness: &agent_yaml.PromptHarness{Type: agent_api.ManagedAgentHarnessGitHubCopilot}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + managed := agent_yaml.PromptAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Name: "rai-agent", + Kind: agent_yaml.AgentKindPrompt, + }, + Model: "gpt-4.1-mini", + Instructions: "be helpful", + Harness: test.harness, + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + } + + request, err := agent_yaml.CreatePromptAgentAPIRequest(managed, nil) + require.NoError(t, err) + + definition, ok := request.Definition.(agent_api.ManagedAgentDefinition) + require.True(t, ok) + require.NotNil(t, definition.RaiConfig) + require.Equal(t, raiPolicyID, definition.RaiConfig.RaiPolicyName) + }) + } +} + +// TestPoliciesNodeAbsent verifies the deploy path is untouched for agents that +// declare no policy. +func TestPoliciesNodeAbsent(t *testing.T) { + t.Parallel() + + g := &promptGraph{managed: &agent_yaml.PromptAgent{}} + require.Nil(t, policiesNode(g, nil)) +} + +// TestPoliciesNodeMissingPolicyFallsBack verifies a policy the account does not +// have is replaced with the account's built-in default and reported, rather +// than failing a deploy over a value the author can edit afterwards. +func TestPoliciesNodeMissingPolicyFallsBack(t *testing.T) { + t.Parallel() + + g := &promptGraph{managed: &agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + }} + var warnings []string + g.warn = func(message string) { warnings = append(warnings, message) } + + const defaultID = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/" + + "my-rg/providers/Microsoft.CognitiveServices/accounts/my-account/raiPolicies/Microsoft.Default" + + node := policiesNode(g, func() (raiPolicyLister, error) { + return func(context.Context, azure.RaiPolicyRef) ([]azure.RaiPolicyInfo, error) { + return []azure.RaiPolicyInfo{ + {Name: "team-strict", ResourceID: "/custom"}, + {Name: "Microsoft.Default", ResourceID: defaultID, SystemManaged: true}, + }, nil + }, nil + }) + require.NotNil(t, node) + require.NoError(t, node.Validate()) + require.NoError(t, node.Resolve(t.Context())) + + // The agent keeps a guardrail, and it is the built-in rather than the + // custom policy that happened to be on the account. + require.Equal(t, defaultID, g.managed.Policies[0].RaiPolicyName) + require.Len(t, warnings, 1) + require.Contains(t, warnings[0], "Microsoft.DefaultV2") + require.Contains(t, warnings[0], "my-account") + require.Contains(t, warnings[0], "Microsoft.Default") +} + +// TestPoliciesNodeMissingPolicyWithoutFallback verifies an account carrying no +// built-in policy publishes without guardrails instead of failing. The +// account's own default content filters still apply. +func TestPoliciesNodeMissingPolicyWithoutFallback(t *testing.T) { + t.Parallel() + + g := &promptGraph{managed: &agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + }} + var warnings []string + g.warn = func(message string) { warnings = append(warnings, message) } + + node := policiesNode(g, func() (raiPolicyLister, error) { + return func(context.Context, azure.RaiPolicyRef) ([]azure.RaiPolicyInfo, error) { + return nil, nil + }, nil + }) + require.NotNil(t, node) + require.NoError(t, node.Resolve(t.Context())) + + require.Empty(t, g.managed.Policies) + require.Len(t, warnings, 1) + require.Contains(t, warnings[0], "Microsoft.DefaultV2") +} + +// TestPoliciesNodePresentPolicy verifies a policy that exists resolves cleanly +// and is left alone. +func TestPoliciesNodePresentPolicy(t *testing.T) { + t.Parallel() + + g := &promptGraph{managed: &agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + }} + + node := policiesNode(g, func() (raiPolicyLister, error) { + return func(context.Context, azure.RaiPolicyRef) ([]azure.RaiPolicyInfo, error) { + // Casing differs from the declared ID: ARM echoes back whatever the + // caller used, so this must not read as a different policy. + return []azure.RaiPolicyInfo{{Name: "microsoft.defaultv2", SystemManaged: true}}, nil + }, nil + }) + require.NotNil(t, node) + require.NoError(t, node.Resolve(t.Context())) + require.Equal(t, raiPolicyID, g.managed.Policies[0].RaiPolicyName) +} + +// TestPoliciesNodeLookupFailureIsNotFatal verifies a developer without the role +// to read policies can still deploy: the service remains the authority on +// whether the policy is usable. +func TestPoliciesNodeLookupFailureIsNotFatal(t *testing.T) { + t.Parallel() + + g := &promptGraph{managed: &agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + }} + + node := policiesNode(g, func() (raiPolicyLister, error) { + return func(context.Context, azure.RaiPolicyRef) ([]azure.RaiPolicyInfo, error) { + return nil, errors.New("authorization failed") + }, nil + }) + require.NotNil(t, node) + require.NoError(t, node.Resolve(t.Context())) + + // The declared policy is left in place: the service is still the authority + // on whether it is usable, and azd could not read the account to know + // otherwise. + require.Equal(t, raiPolicyID, g.managed.Policies[0].RaiPolicyName) +} + +// TestPromptCreateErrorAddsPolicySuggestion verifies a failed create on an agent +// with guardrails points at rai_config, which the service's own message does +// not mention. +func TestPromptCreateErrorAddsPolicySuggestion(t *testing.T) { + t.Parallel() + + managed := agent_yaml.PromptAgent{ + Harness: &agent_yaml.PromptHarness{Type: agent_api.ManagedAgentHarnessGitHubCopilot}, + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + } + + err := promptCreateError(errors.New("BadRequest"), &managed) + require.ErrorContains(t, err, "BadRequest") + + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + require.Contains(t, localErr.Suggestion, "Responsible AI policy") + require.Contains(t, localErr.Suggestion, "harness") +} + +// TestPromptCreateErrorWithoutPolicyIsUnchanged verifies agents without +// guardrails keep the existing service error verbatim. +func TestPromptCreateErrorWithoutPolicyIsUnchanged(t *testing.T) { + t.Parallel() + + err := promptCreateError(errors.New("BadRequest"), &agent_yaml.PromptAgent{}) + + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + require.NotContains(t, localErr.Suggestion, "Responsible AI policy") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go index 38767b1b2f5..507ec726159 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go @@ -15,6 +15,7 @@ import ( "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/azure" + "azureaiagent/internal/pkg/envkey" "github.com/braydonk/yaml" ) @@ -55,6 +56,13 @@ type skillBundle struct { type toolboxRef struct { Name string Version string + // MCPEndpoint is the toolbox's MCP url as published by its sibling + // `host: azure.ai.toolbox` service, when that service deployed in this + // environment. It is authoritative: the toolboxes extension owns the + // toolbox's lifecycle and knows the endpoint it actually created, whereas + // azd can only guess one from the name and version. Empty for a toolbox + // that has no sibling service, e.g. one created outside of azure.yaml. + MCPEndpoint string } // toolboxAttachment is the result of registering or resolving a toolbox: the @@ -81,18 +89,46 @@ type toolboxBuilder interface { ResolveToolbox(ctx context.Context, ref toolboxRef) (toolboxAttachment, error) } -// skillAttacher publishes skill bundles for a harness-less prompt agent, which -// reaches them through a shell tool instead of a toolbox. It returns the -// registered skill names. Same seam purpose as toolboxBuilder. -type skillAttacher interface { - AttachSkills(ctx context.Context, skills []skillBundle) ([]string, error) +// SkillBundleRef is the identity `azd ai agent init` needs to emit one +// `host: azure.ai.skill` sibling service per skills// folder: the folder to +// point the service's archive: at, and the name and description its SKILL.md +// declares. +type SkillBundleRef struct { + // Name is the skill name from SKILL.md frontmatter, defaulting to the + // folder name. It becomes the azure.yaml service key, which the skills + // extension uses as the skill name. + Name string + // Description is the skill description from SKILL.md frontmatter. + Description string + // RelPath is the bundle folder relative to the agent directory, in + // forward-slash form (e.g. "skills/code-review"), ready to be joined onto + // the service path and written as archive:. + RelPath string } -// harnessSkillPublisher publishes skill bundles for a harnessed agent. It -// returns the resolved name and version of each, because a harness skill -// reference has to pin a version. -type harnessSkillPublisher interface { - PublishSkills(ctx context.Context, skills []skillBundle) ([]publishedSkill, error) +// ScanSkillBundles returns one SkillBundleRef per skills// folder under +// agentDir, sorted by folder name. A missing or empty folder returns (nil, nil). +// +// It exists so the init command can emit the sibling skill services without +// reaching into the deploy engine's internal bundle representation. +func ScanSkillBundles(agentDir string) ([]SkillBundleRef, error) { + bundles, err := scanSkillsDir(agentDir) + if err != nil { + return nil, err + } + refs := make([]SkillBundleRef, 0, len(bundles)) + for _, b := range bundles { + name := strings.TrimSpace(b.Meta.Name) + if name == "" { + name = b.Dir + } + refs = append(refs, SkillBundleRef{ + Name: name, + Description: b.Meta.Description, + RelPath: promptSkillsDirName + "/" + b.Dir, + }) + } + return refs, nil } // scanSkillsDir returns the skill bundles under /skills, one per @@ -314,8 +350,8 @@ func injectShellTool(managed *agent_yaml.PromptAgent) { } // skillsShellNode builds the skills graph node for a *harness-less* prompt -// agent: bundles are published as skill versions, referenced by name on the -// definition, and made runnable by a shell tool. +// agent: bundles are referenced by name on the definition and made runnable by +// a shell tool. // // This is the counterpart to toolboxNode, which serves managed agents. The two // are mutually exclusive — a toolbox is only reachable from inside a harness @@ -325,7 +361,6 @@ func skillsShellNode( g *promptGraph, skills []skillBundle, ref *agent_yaml.ToolboxReference, - newAttacher func() (skillAttacher, error), ) *promptNode { if len(skills) == 0 && ref == nil { return nil @@ -346,29 +381,16 @@ func skillsShellNode( "skills in a skills/ folder next to agent.yaml", ) } - for _, s := range skills { - if strings.TrimSpace(s.Meta.Instructions) == "" { - return exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("skill %q has no instructions (empty SKILL.md body)", s.Dir), - "add Markdown content below the frontmatter in the skill's SKILL.md", - ) - } - } - return nil + return validateSkillBundleInstructions(skills) }, - Resolve: func(ctx context.Context) error { - attacher, err := newAttacher() + Resolve: func(_ context.Context) error { + resolved, err := resolveSkillMarkers(skills, g.env) if err != nil { return err } - names, err := attacher.AttachSkills(ctx, skills) - if err != nil { - return err - } - for _, name := range names { - if !slices.Contains(g.managed.Skills, name) { - g.managed.Skills = append(g.managed.Skills, name) + for _, s := range resolved { + if !slices.Contains(g.managed.Skills, s.Name) { + g.managed.Skills = append(g.managed.Skills, s.Name) } } injectShellTool(g.managed) @@ -378,8 +400,8 @@ func skillsShellNode( } // skillsHarnessNode builds the skills graph node for a *harnessed* prompt -// agent: bundles are published as skill versions and pinned onto the harness, -// which provisions them into the sandbox that starts up to run the agent. +// agent: bundles are pinned onto the harness, which provisions them into the +// sandbox that starts up to run the agent. // // This is the counterpart to skillsShellNode, which serves harness-less agents. // Nothing is attached as a tool here — a skill is not a tool, and the harness @@ -387,45 +409,29 @@ func skillsShellNode( func skillsHarnessNode( g *promptGraph, skills []skillBundle, - newPublisher func() (harnessSkillPublisher, error), ) *promptNode { if len(skills) == 0 { return nil } return &promptNode{ - Kind: nodeSkill, - ID: promptSkillsDirName, - Validate: func() error { - for _, s := range skills { - if strings.TrimSpace(s.Meta.Instructions) == "" { - return exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("skill %q has no instructions (empty SKILL.md body)", s.Dir), - "add Markdown content below the frontmatter in the skill's SKILL.md", - ) - } - } - return nil - }, - Resolve: func(ctx context.Context) error { - publisher, err := newPublisher() + Kind: nodeSkill, + ID: promptSkillsDirName, + Validate: func() error { return validateSkillBundleInstructions(skills) }, + Resolve: func(_ context.Context) error { + resolved, err := resolveSkillMarkers(skills, g.env) if err != nil { return err } - published, err := publisher.PublishSkills(ctx, skills) - if err != nil { - return err - } - for _, s := range published { + for _, s := range resolved { if slices.ContainsFunc(g.managed.HarnessSkills, func(existing agent_yaml.HarnessSkillRef) bool { return existing.Name == s.Name }) { continue } - // Always pin the version that was just published, even when the - // author did not pin one in SKILL.md. The service returns a 500 - // for a skill reference with no version, so "follow the default" - // is not an option the wire format actually offers. + // Always pin the version the skill service published, even when + // the author did not pin one in SKILL.md. The service returns a + // 500 for a skill reference with no version, so "follow the + // default" is not an option the wire format actually offers. g.managed.HarnessSkills = append(g.managed.HarnessSkills, agent_yaml.HarnessSkillRef{ Name: s.Name, Version: s.Version, @@ -436,6 +442,22 @@ func skillsHarnessNode( } } +// validateSkillBundleInstructions rejects a bundle whose SKILL.md has no body. +// The skills extension uploads the folder as-is, so an empty body would publish +// a skill version that instructs the agent to do nothing. +func validateSkillBundleInstructions(skills []skillBundle) error { + for _, s := range skills { + if strings.TrimSpace(s.Meta.Instructions) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("skill %q has no instructions (empty SKILL.md body)", s.Dir), + "add Markdown content below the frontmatter in the skill's SKILL.md", + ) + } + } + return nil +} + // toolboxNode attaches an existing shared toolbox by reference, as an mcp tool. // // It is reachable only from an explicit `toolbox:` block in agent.yaml. Skills @@ -469,7 +491,18 @@ func toolboxNode( if err != nil { return err } - attachment, err := builder.ResolveToolbox(ctx, toolboxRef{Name: ref.Name, Version: ref.Version}) + // Prefer the endpoint the sibling azure.ai.toolbox service published + // over one synthesized from the name, so the two extensions cannot + // disagree about where the toolbox lives. + mcpEndpoint, err := siblingToolboxEndpoint(ref.Name, g.env) + if err != nil { + return err + } + attachment, err := builder.ResolveToolbox(ctx, toolboxRef{ + Name: ref.Name, + Version: ref.Version, + MCPEndpoint: mcpEndpoint, + }) if err != nil { return err } @@ -493,114 +526,101 @@ type foundryToolboxBuilder struct { projectEndpoint string } -// publishedSkill is a skill bundle after it has been registered as a skill -// version on the project. -type publishedSkill struct { +// resolvedSkill is a skill bundle matched to the version that its sibling +// `host: azure.ai.skill` service published. +type resolvedSkill struct { Name string Version string - // Pinned records that the author fixed a version in SKILL.md frontmatter, - // rather than following the skill's default_version. - Pinned bool } -// publishSkillBundles uploads and promotes each skill bundle, returning the -// registered name and version of each. +// resolveSkillMarkers maps each skills// bundle to the version its sibling +// azure.ai.skill service created, read from the deployment markers that service +// writes into the azd environment (SKILL__VERSION). // -// This is shared by both prompt-agent flavors — a harnessed agent and a -// harness-less one differ in how skills are *reached*, not in how they are -// published. It takes the skills client directly so neither path has to hold a -// toolbox client it would not use. -func publishSkillBundles( - ctx context.Context, client *azure.FoundrySkillsClient, skills []skillBundle, -) ([]publishedSkill, error) { - published := make([]publishedSkill, 0, len(skills)) +// azd does not upload skill bundles itself. Creating and versioning a Foundry +// skill belongs to the azure.ai.skills extension, which owns the +// `host: azure.ai.skill` service target; this extension only attaches the +// resulting versioned reference to the agent. A bundle with no marker means its +// service is missing from azure.yaml or has not been deployed yet, both of which +// the author has to fix. +func resolveSkillMarkers(skills []skillBundle, env map[string]string) ([]resolvedSkill, error) { + resolved := make([]resolvedSkill, 0, len(skills)) for _, s := range skills { - // Upload every file in the bundle (SKILL.md plus any references/, - // assets/, or other supporting files), not just SKILL.md. The service - // parses SKILL.md itself from the uploaded bundle; using the JSON - // inline_content path here would silently drop everything except - // SKILL.md's body. - files, err := readSkillBundleFiles(s.Path) - if err != nil { - return nil, err + name := strings.TrimSpace(s.Meta.Name) + if name == "" { + name = s.Dir } - - version, err := client.CreateSkillVersionFromFiles(ctx, s.Meta.Name, files) - if err != nil { - return nil, fmt.Errorf("registering skill %q: %w", s.Meta.Name, err) + versionKey := envkey.SkillVersion(name) + version := strings.TrimSpace(env[versionKey]) + if version == "" { + return nil, exterrors.Dependency( + exterrors.CodeFoundryDependencyNotReady, + fmt.Sprintf("skill %q has not been published (%s is not set)", name, versionKey), + fmt.Sprintf( + "add a service to azure.yaml with host: %s named %q, pointing archive: at the "+ + "%s/%s folder, and list %q in the agent service's uses:, then run "+ + "'azd deploy --all'. Re-running 'azd ai agent init' writes those entries for you", + foundrySkillHost, name, promptSkillsDirName, s.Dir, name, + ), + ) } - - // Creating a version does NOT make it the skill's default_version — - // the Foundry API only auto-promotes the very first version. Without - // this, redeploying with changed skill content registers a new - // version that the Foundry portal's skill view (and any unversioned - // reference) never surfaces, making the update look like it didn't - // happen. Promote every newly created version to default so the - // latest deploy is always what's active. - if err := client.PromoteSkillVersion(ctx, version.Name, version.Version); err != nil { - return nil, fmt.Errorf("promoting skill %q to version %s: %w", s.Meta.Name, version.Version, err) + // The marker is scoped to the project it was created in. Reusing a + // version id from a different Foundry project would pin the agent to a + // skill that does not exist here, which the service reports as a + // generic failure at run time rather than at deploy. + projectKey := envkey.SkillProjectEndpoint(name) + if declared := strings.TrimSpace(env[projectKey]); declared != "" && + !sameProjectEndpoint(declared, env["FOUNDRY_PROJECT_ENDPOINT"]) { + return nil, exterrors.Dependency( + exterrors.CodeFoundryDependencyNotReady, + fmt.Sprintf("skill %q was published to a different Foundry project (%s)", name, projectKey), + "run 'azd deploy --all' so the skill is republished to the project this agent targets", + ) } - - published = append(published, publishedSkill{ - Name: version.Name, - Version: version.Version, - Pinned: strings.TrimSpace(s.Meta.Version) != "", - }) + resolved = append(resolved, resolvedSkill{Name: name, Version: version}) } - return published, nil + return resolved, nil } -// foundrySkillPublisher is the live publisher for both prompt-agent flavors. It -// holds only the skills client: neither path creates a toolbox, a toolbox -// version or a project connection. A harness-less agent runs its skills through -// a shell tool, and a harnessed agent has them provisioned into its sandbox. -type foundrySkillPublisher struct { - skills *azure.FoundrySkillsClient -} - -// AttachSkills publishes the bundles and returns their registered names. -func (p *foundrySkillPublisher) AttachSkills(ctx context.Context, skills []skillBundle) ([]string, error) { - published, err := publishSkillBundles(ctx, p.skills, skills) - if err != nil { - return nil, err - } - names := make([]string, 0, len(published)) - for _, s := range published { - names = append(names, s.Name) +// siblingToolboxEndpoint returns the MCP url that the toolbox's sibling +// `host: azure.ai.toolbox` service published into the azd environment, or an +// empty string when the toolbox has no sibling service. +// +// It also guards against a stale marker: the toolboxes extension records the +// project it deployed into alongside the endpoint, and an endpoint belonging to +// a different project would silently point the agent at a toolbox it cannot +// reach. +func siblingToolboxEndpoint(name string, env map[string]string) (string, error) { + endpoint := strings.TrimSpace(env[envkey.ToolboxMCPEndpoint(name)]) + if endpoint == "" { + return "", nil } - return names, nil -} - -// PublishSkills publishes the bundles and returns their name and version. -func (p *foundrySkillPublisher) PublishSkills( - ctx context.Context, skills []skillBundle, -) ([]publishedSkill, error) { - return publishSkillBundles(ctx, p.skills, skills) -} - -// newFoundrySkillPublisher constructs the live publisher from prompt settings. -func newFoundrySkillPublisher(settings *PromptAgentSettings) (*foundrySkillPublisher, error) { - if settings == nil || strings.TrimSpace(settings.ProjectEndpoint) == "" { - return nil, exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - "a Foundry project endpoint is required to register skills", - "run `azd up` to provision a Foundry project, or remove the skills/ folder", + projectKey := envkey.ToolboxProjectEndpoint(name) + if declared := strings.TrimSpace(env[projectKey]); declared != "" && + !sameProjectEndpoint(declared, env["FOUNDRY_PROJECT_ENDPOINT"]) { + return "", exterrors.Dependency( + exterrors.CodeFoundryDependencyNotReady, + fmt.Sprintf("toolbox %q was deployed to a different Foundry project (%s)", name, projectKey), + "run 'azd deploy --all' so the toolbox is redeployed to the project this agent targets", ) } - return &foundrySkillPublisher{ - skills: azure.NewFoundrySkillsClient(settings.ProjectEndpoint, promptCredential()), - }, nil + return endpoint, nil } // ResolveToolbox confirms an existing toolbox and returns its MCP url plus the -// backing project connection. When the reference pins a version, the -// version-specific (developer) endpoint is used; otherwise the consumer +// backing project connection. The url published by the toolbox's sibling +// azure.ai.toolbox service wins when present; otherwise the toolbox is looked up +// directly and its url derived from the reference -- the version-specific +// (developer) endpoint when the reference pins a version, else the consumer // endpoint that always serves the default_version. func (b *foundryToolboxBuilder) ResolveToolbox(ctx context.Context, ref toolboxRef) (toolboxAttachment, error) { - if _, err := b.toolboxes.GetToolbox(ctx, ref.Name); err != nil { - return toolboxAttachment{}, fmt.Errorf("resolving toolbox %q: %w", ref.Name, err) + mcpURL := ref.MCPEndpoint + if mcpURL == "" { + if _, err := b.toolboxes.GetToolbox(ctx, ref.Name); err != nil { + return toolboxAttachment{}, fmt.Errorf("resolving toolbox %q: %w", ref.Name, err) + } + mcpURL = b.mcpURL(ref.Name, ref.Version) } - mcpURL := b.mcpURL(ref.Name, ref.Version) connName, err := b.ensureToolboxConnection(ctx, ref.Name, mcpURL) if err != nil { return toolboxAttachment{}, err @@ -672,51 +692,6 @@ func (b *foundryToolboxBuilder) mcpURL(name, version string) string { // MCP endpoint URLs. const toolboxMcpApiVersion = "v1" -// readSkillBundleFiles reads every file under a skill bundle directory — -// SKILL.md plus any references/, assets/, or other supporting files, at any -// nesting depth — into a map of bundle-relative path (forward-slash -// separated) to raw content, so the entire bundle can be uploaded together -// via the multipart skill-version API. Without this, only SKILL.md would ever -// reach the service and any files it references (scripts, docs, assets) -// would be silently dropped. -func readSkillBundleFiles(bundleDir string) (map[string][]byte, error) { - files := map[string][]byte{} - err := filepath.WalkDir(bundleDir, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - // WalkDir does not follow symlinks, but os.ReadFile does. Reject links - // outright so a bundle cannot exfiltrate arbitrary local files. - if d.Type()&os.ModeSymlink != 0 { - return exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("skill bundle file %q is a symbolic link", path), - "replace the link with the file itself; symlinks are not packaged", - ) - } - content, readErr := os.ReadFile(path) //nolint:gosec // path derived from the agent's skills/ folder - if readErr != nil { - return readErr - } - rel, relErr := filepath.Rel(bundleDir, path) - if relErr != nil { - return relErr - } - files[filepath.ToSlash(rel)] = content - return nil - }) - if err != nil { - return nil, fmt.Errorf("reading skill bundle %q: %w", bundleDir, err) - } - if len(files) == 0 { - return nil, fmt.Errorf("skill bundle %q contains no files", bundleDir) - } - return files, nil -} - // newFoundryToolboxBuilder constructs the live builder from prompt settings. func newFoundryToolboxBuilder(settings *PromptAgentSettings) (toolboxBuilder, error) { if settings == nil || strings.TrimSpace(settings.ProjectEndpoint) == "" { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_bundle_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_bundle_test.go deleted file mode 100644 index 75b01ba3116..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_bundle_test.go +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package project - -import ( - "os" - "path/filepath" - "sort" - "testing" -) - -// TestReadSkillBundleFiles_ReadsAllNestedFiles verifies that every file in a -// skill bundle — SKILL.md plus references/, assets/, and scripts/ subfolders — -// is picked up, not just SKILL.md. This is the core of the reported bug: only -// SKILL.md was ever read/uploaded, silently dropping everything else. -func TestReadSkillBundleFiles_ReadsAllNestedFiles(t *testing.T) { - dir := t.TempDir() - files := map[string]string{ - "SKILL.md": "---\nname: s\ndescription: d\n---\nbody", - "references/tone.md": "tone guidance", - "assets/logo.svg": "", - "scripts/analysis.py": "print('hi')", - } - for rel, content := range files { - full := filepath.Join(dir, filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil { - t.Fatalf("mkdir for %s: %v", rel, err) - } - if err := os.WriteFile(full, []byte(content), 0o600); err != nil { - t.Fatalf("write %s: %v", rel, err) - } - } - - got, err := readSkillBundleFiles(dir) - if err != nil { - t.Fatalf("readSkillBundleFiles: %v", err) - } - - if len(got) != len(files) { - t.Fatalf("got %d files, want %d: %v", len(got), len(files), keysOf(got)) - } - for rel, want := range files { - content, ok := got[rel] - if !ok { - t.Errorf("missing bundle file %q in result", rel) - continue - } - if string(content) != want { - t.Errorf("file %q content: got %q, want %q", rel, content, want) - } - } -} - -func keysOf(m map[string][]byte) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - -func TestReadSkillBundleFiles_EmptyDirErrors(t *testing.T) { - dir := t.TempDir() - if _, err := readSkillBundleFiles(dir); err == nil { - t.Fatal("expected an error for an empty bundle directory") - } -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go index d821df899b7..fa5bb26977f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go @@ -14,6 +14,9 @@ import ( "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/envkey" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) // testPromptHarness returns a minimal harness block. Each caller gets its own @@ -39,6 +42,51 @@ func (b *fakeToolboxBuilder) ResolveToolbox(_ context.Context, ref toolboxRef) ( return toolboxAttachment{McpURL: b.mcpURL, ConnectionName: b.connName}, nil } +// TestToolboxNode_PrefersSiblingEndpoint verifies the toolbox node hands the +// builder the MCP url the sibling azure.ai.toolbox service published, rather +// than letting the builder synthesize one from the name. The toolboxes extension +// owns the toolbox's lifecycle and knows the endpoint it actually created, so +// the two extensions must not be free to disagree about where it lives. +func TestToolboxNode_PrefersSiblingEndpoint(t *testing.T) { + published := "https://acct.services.ai.azure.com/toolboxes/tb/mcp" + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + g := &promptGraph{managed: managed, bindings: map[string]any{}, env: map[string]string{ + envkey.ToolboxMCPEndpoint("tb"): published, + envkey.ToolboxProjectEndpoint("tb"): "https://acct.services.ai.azure.com/api/projects/p", + "FOUNDRY_PROJECT_ENDPOINT": "https://acct.services.ai.azure.com/api/projects/p", + }} + + builder := &fakeToolboxBuilder{} + node := toolboxNode(g, &agent_yaml.ToolboxReference{Name: "tb"}, func() (toolboxBuilder, error) { + return builder, nil + }) + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if builder.lastRef.MCPEndpoint != published { + t.Errorf("published endpoint: got %q, want %q", builder.lastRef.MCPEndpoint, published) + } +} + +// TestToolboxNode_RejectsCrossProjectEndpoint verifies a marker left over from a +// different Foundry project fails the deploy instead of pointing the agent at a +// toolbox it cannot reach, which the service would only report at run time. +func TestToolboxNode_RejectsCrossProjectEndpoint(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + g := &promptGraph{managed: managed, bindings: map[string]any{}, env: map[string]string{ + envkey.ToolboxMCPEndpoint("tb"): "https://other.services.ai.azure.com/toolboxes/tb/mcp", + envkey.ToolboxProjectEndpoint("tb"): "https://other.services.ai.azure.com/api/projects/q", + "FOUNDRY_PROJECT_ENDPOINT": "https://acct.services.ai.azure.com/api/projects/p", + }} + + node := toolboxNode(g, &agent_yaml.ToolboxReference{Name: "tb"}, func() (toolboxBuilder, error) { + return &fakeToolboxBuilder{}, nil + }) + if err := node.Resolve(context.Background()); err == nil { + t.Fatal("expected a toolbox published to another project to fail the deploy") + } +} + func writeSkillsDir(t *testing.T, skills map[string]string) string { t.Helper() dir := t.TempDir() @@ -247,68 +295,27 @@ func TestToolboxNode_NoneReturnsNil(t *testing.T) { } } -// fakeSkillAttacher records the bundles it was given and returns their names. -type fakeSkillAttacher struct { - attachCalls int - lastSkills []skillBundle - names []string - err error -} - -func (a *fakeSkillAttacher) AttachSkills(_ context.Context, skills []skillBundle) ([]string, error) { - a.attachCalls++ - a.lastSkills = skills - if a.err != nil { - return nil, a.err - } - if a.names != nil { - return a.names, nil +// skillMarkers builds the azd environment a deployed sibling azure.ai.skill +// service leaves behind: one SKILL__VERSION entry per published skill. +func skillMarkers(nameToVersion map[string]string) map[string]string { + env := map[string]string{} + for name, version := range nameToVersion { + env[envkey.SkillVersion(name)] = version } - names := make([]string, 0, len(skills)) - for _, s := range skills { - names = append(names, s.Meta.Name) - } - return names, nil + return env } func TestSkillsShellNode_NoneReturnsNil(t *testing.T) { g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} - node := skillsShellNode(g, nil, nil, func() (skillAttacher, error) { return nil, nil }) + node := skillsShellNode(g, nil, nil) if node != nil { t.Fatal("expected nil node when no skills and no reference") } } -// fakeHarnessSkillPublisher records the bundles it was given and echoes them -// back as published skills at a fixed version. -type fakeHarnessSkillPublisher struct { - calls int - lastSkills []skillBundle - published []publishedSkill - err error -} - -func (p *fakeHarnessSkillPublisher) PublishSkills( - _ context.Context, skills []skillBundle, -) ([]publishedSkill, error) { - p.calls++ - p.lastSkills = skills - if p.err != nil { - return nil, p.err - } - if p.published != nil { - return p.published, nil - } - out := make([]publishedSkill, 0, len(skills)) - for _, s := range skills { - out = append(out, publishedSkill{Name: s.Meta.Name, Version: "7"}) - } - return out, nil -} - func TestSkillsHarnessNode_NoneReturnsNil(t *testing.T) { g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} - node := skillsHarnessNode(g, nil, func() (harnessSkillPublisher, error) { return nil, nil }) + node := skillsHarnessNode(g, nil) if node != nil { t.Fatal("expected nil node when there are no skills") } @@ -316,19 +323,23 @@ func TestSkillsHarnessNode_NoneReturnsNil(t *testing.T) { // TestSkillsHarnessNode_PinsVersionsAndAttachesNoTool is the core of the // harnessed skills contract: skills land on the harness as versioned -// references, and nothing is added to tools. A skill is not a tool, and the -// toolbox that used to carry them is service-owned. +// references taken from the sibling skill services' markers, and nothing is +// added to tools. A skill is not a tool, and the toolbox that used to carry +// them is service-owned. func TestSkillsHarnessNode_PinsVersionsAndAttachesNoTool(t *testing.T) { managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: testPromptHarness()} managed.Name = "agent" - g := &promptGraph{managed: managed, bindings: map[string]any{}} - pub := &fakeHarnessSkillPublisher{} + g := &promptGraph{ + managed: managed, + bindings: map[string]any{}, + env: skillMarkers(map[string]string{"skill-a": "7", "skill-b": "7"}), + } skills := []skillBundle{ {Dir: "skill-a", Meta: skillMeta{Name: "skill-a", Description: "d", Instructions: "body"}}, {Dir: "skill-b", Meta: skillMeta{Name: "skill-b", Description: "d", Instructions: "body"}}, } - node := skillsHarnessNode(g, skills, func() (harnessSkillPublisher, error) { return pub, nil }) + node := skillsHarnessNode(g, skills) if node == nil { t.Fatal("expected a skills node") } @@ -342,9 +353,6 @@ func TestSkillsHarnessNode_PinsVersionsAndAttachesNoTool(t *testing.T) { t.Fatalf("resolve: %v", err) } - if pub.calls != 1 { - t.Errorf("expected 1 publish call, got %d", pub.calls) - } want := []agent_yaml.HarnessSkillRef{ {Name: "skill-a", Version: "7"}, {Name: "skill-b", Version: "7"}, @@ -362,18 +370,19 @@ func TestSkillsHarnessNode_PinsVersionsAndAttachesNoTool(t *testing.T) { // TestSkillsHarnessNode_PinsVersionEvenWhenUnpinned guards the workaround for // the service returning 500 for a reference with no version: azd always sends -// the version it just published, whether or not SKILL.md pinned one. +// the version the skill service published, whether or not SKILL.md pinned one. func TestSkillsHarnessNode_PinsVersionEvenWhenUnpinned(t *testing.T) { managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: testPromptHarness()} - g := &promptGraph{managed: managed, bindings: map[string]any{}} - pub := &fakeHarnessSkillPublisher{ - published: []publishedSkill{{Name: "skill-a", Version: "3", Pinned: false}}, + g := &promptGraph{ + managed: managed, + bindings: map[string]any{}, + env: skillMarkers(map[string]string{"skill-a": "3"}), } skills := []skillBundle{{Dir: "skill-a", Meta: skillMeta{ Name: "skill-a", Description: "d", Instructions: "body", }}} - node := skillsHarnessNode(g, skills, func() (harnessSkillPublisher, error) { return pub, nil }) + node := skillsHarnessNode(g, skills) if err := node.Resolve(context.Background()); err != nil { t.Fatalf("resolve: %v", err) } @@ -390,13 +399,16 @@ func TestSkillsHarnessNode_ResolveIsIdempotent(t *testing.T) { Harness: testPromptHarness(), HarnessSkills: []agent_yaml.HarnessSkillRef{{Name: "skill-a", Version: "7"}}, } - g := &promptGraph{managed: managed, bindings: map[string]any{}} - pub := &fakeHarnessSkillPublisher{} + g := &promptGraph{ + managed: managed, + bindings: map[string]any{}, + env: skillMarkers(map[string]string{"skill-a": "7"}), + } skills := []skillBundle{{Dir: "skill-a", Meta: skillMeta{ Name: "skill-a", Description: "d", Instructions: "body", }}} - node := skillsHarnessNode(g, skills, func() (harnessSkillPublisher, error) { return pub, nil }) + node := skillsHarnessNode(g, skills) if err := node.Resolve(context.Background()); err != nil { t.Fatalf("resolve: %v", err) } @@ -409,10 +421,9 @@ func TestSkillsHarnessNode_ResolveIsIdempotent(t *testing.T) { func TestSkillsHarnessNode_RejectsEmptyInstructions(t *testing.T) { managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: testPromptHarness()} g := &promptGraph{managed: managed, bindings: map[string]any{}} - pub := &fakeHarnessSkillPublisher{} skills := []skillBundle{{Dir: "empty", Meta: skillMeta{Name: "empty", Description: "d"}}} - node := skillsHarnessNode(g, skills, func() (harnessSkillPublisher, error) { return pub, nil }) + node := skillsHarnessNode(g, skills) err := node.Validate() if err == nil { @@ -421,26 +432,51 @@ func TestSkillsHarnessNode_RejectsEmptyInstructions(t *testing.T) { if !strings.Contains(err.Error(), "empty") { t.Errorf("error should name the skill, got: %v", err) } - if pub.calls != 0 { - t.Errorf("validation failure must not publish skills, got %d calls", pub.calls) - } } -func TestSkillsHarnessNode_PublisherErrorPropagates(t *testing.T) { +// TestSkillsHarnessNode_MissingMarkerFails covers the case the replacement of +// azd's own publisher introduces: a skills/ folder with no sibling +// azure.ai.skill service, so nothing ever created the skill. The deploy must +// fail with the azure.yaml entry to add rather than silently drop the skill. +func TestSkillsHarnessNode_MissingMarkerFails(t *testing.T) { managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: testPromptHarness()} - g := &promptGraph{managed: managed, bindings: map[string]any{}} - pub := &fakeHarnessSkillPublisher{err: errors.New("boom")} + g := &promptGraph{managed: managed, bindings: map[string]any{}, env: map[string]string{}} skills := []skillBundle{{Dir: "skill-a", Meta: skillMeta{ Name: "skill-a", Description: "d", Instructions: "body", }}} - node := skillsHarnessNode(g, skills, func() (harnessSkillPublisher, error) { return pub, nil }) + node := skillsHarnessNode(g, skills) - if err := node.Resolve(context.Background()); err == nil { - t.Fatal("expected the publish error to propagate") + err := node.Resolve(context.Background()) + if err == nil { + t.Fatal("expected an unpublished skill to fail the deploy") + } + if !strings.Contains(err.Error(), "SKILL_SKILL_A_VERSION") { + t.Errorf("error should name the missing marker, got: %v", err) + } + svcErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected a structured error, got %T", err) + } + if !strings.Contains(svcErr.Suggestion, "azure.ai.skill") { + t.Errorf("suggestion should name the host to declare, got: %v", svcErr.Suggestion) } if len(managed.HarnessSkills) != 0 { - t.Errorf("failed publish must leave the definition untouched, got %+v", managed.HarnessSkills) + t.Errorf("failed resolve must leave the definition untouched, got %+v", managed.HarnessSkills) + } +} + +// TestResolveSkillMarkers_RejectsCrossProjectVersion covers a stale marker left +// by a deploy against a different Foundry project: the version id would not +// resolve there, and the service reports that only at run time. +func TestResolveSkillMarkers_RejectsCrossProjectVersion(t *testing.T) { + env := skillMarkers(map[string]string{"skill-a": "7"}) + env[envkey.SkillProjectEndpoint("skill-a")] = "https://other.services.ai.azure.com/api/projects/other" + env["FOUNDRY_PROJECT_ENDPOINT"] = "https://mine.services.ai.azure.com/api/projects/mine" + + skills := []skillBundle{{Dir: "skill-a", Meta: skillMeta{Name: "skill-a"}}} + if _, err := resolveSkillMarkers(skills, env); err == nil { + t.Fatal("expected a marker from another project to be rejected") } } @@ -452,10 +488,9 @@ func TestSkillsShellNode_RejectsToolboxReference(t *testing.T) { managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} managed.Name = "agent" g := &promptGraph{managed: managed, bindings: map[string]any{}} - fake := &fakeSkillAttacher{} ref := &agent_yaml.ToolboxReference{Name: "existing-tb", Version: "2"} - node := skillsShellNode(g, nil, ref, func() (skillAttacher, error) { return fake, nil }) + node := skillsShellNode(g, nil, ref) if node == nil { t.Fatal("expected a skills node") } @@ -467,9 +502,6 @@ func TestSkillsShellNode_RejectsToolboxReference(t *testing.T) { if !strings.Contains(err.Error(), "harness") { t.Errorf("error should point at the harness requirement, got: %v", err) } - if fake.attachCalls != 0 { - t.Errorf("validation failure must not publish skills, got %d calls", fake.attachCalls) - } } // TestSkillsShellNode_RejectsEmptyInstructions covers a SKILL.md whose body is @@ -481,9 +513,7 @@ func TestSkillsShellNode_RejectsEmptyInstructions(t *testing.T) { g := &promptGraph{managed: managed, bindings: map[string]any{}} skills := []skillBundle{{Dir: "empty", Meta: skillMeta{Name: "empty", Description: "d"}}} - node := skillsShellNode(g, skills, nil, func() (skillAttacher, error) { - return &fakeSkillAttacher{}, nil - }) + node := skillsShellNode(g, skills, nil) err := node.Validate() if err == nil { @@ -494,20 +524,23 @@ func TestSkillsShellNode_RejectsEmptyInstructions(t *testing.T) { } } -// TestSkillsShellNode_PublishesAndInjectsShell asserts the node's whole job: -// publish the bundles, reference the returned names on the definition, and add -// the shell tool that makes them runnable. -func TestSkillsShellNode_PublishesAndInjectsShell(t *testing.T) { +// TestSkillsShellNode_AttachesAndInjectsShell asserts the node's whole job: +// reference the skills the sibling services published, and add the shell tool +// that makes them runnable. +func TestSkillsShellNode_AttachesAndInjectsShell(t *testing.T) { managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} managed.Name = "agent" - g := &promptGraph{managed: managed, bindings: map[string]any{}} - fake := &fakeSkillAttacher{} + g := &promptGraph{ + managed: managed, + bindings: map[string]any{}, + env: skillMarkers(map[string]string{"skill-a": "1", "skill-b": "1"}), + } skills := []skillBundle{ {Dir: "a", Meta: skillMeta{Name: "skill-a", Description: "d", Instructions: "do a"}}, {Dir: "b", Meta: skillMeta{Name: "skill-b", Description: "d", Instructions: "do b"}}, } - node := skillsShellNode(g, skills, nil, func() (skillAttacher, error) { return fake, nil }) + node := skillsShellNode(g, skills, nil) if node == nil { t.Fatal("expected a skills node") } @@ -521,12 +554,6 @@ func TestSkillsShellNode_PublishesAndInjectsShell(t *testing.T) { t.Fatalf("resolve: %v", err) } - if fake.attachCalls != 1 { - t.Errorf("expected 1 attach call, got %d", fake.attachCalls) - } - if len(fake.lastSkills) != 2 { - t.Errorf("expected both bundles published, got %d", len(fake.lastSkills)) - } if want := []string{"skill-a", "skill-b"}; !slices.Equal(managed.Skills, want) { t.Errorf("skills: got %v, want %v", managed.Skills, want) } @@ -549,13 +576,16 @@ func TestSkillsShellNode_ResolveIsIdempotent(t *testing.T) { Tools: []any{map[string]any{"type": promptSkillShellToolType}}, } managed.Name = "agent" - g := &promptGraph{managed: managed, bindings: map[string]any{}} - fake := &fakeSkillAttacher{} + g := &promptGraph{ + managed: managed, + bindings: map[string]any{}, + env: skillMarkers(map[string]string{"skill-a": "1"}), + } skills := []skillBundle{ {Dir: "a", Meta: skillMeta{Name: "skill-a", Description: "d", Instructions: "do a"}}, } - node := skillsShellNode(g, skills, nil, func() (skillAttacher, error) { return fake, nil }) + node := skillsShellNode(g, skills, nil) if err := node.Resolve(context.Background()); err != nil { t.Fatalf("resolve: %v", err) } @@ -568,23 +598,22 @@ func TestSkillsShellNode_ResolveIsIdempotent(t *testing.T) { } } -// TestSkillsShellNode_AttacherErrorPropagates asserts a publish failure fails -// the deploy rather than leaving the definition half-wired -- an agent that +// TestSkillsShellNode_MissingMarkerFails asserts an unpublished skill fails the +// deploy rather than leaving the definition half-wired -- an agent that // references skills the service never received. -func TestSkillsShellNode_AttacherErrorPropagates(t *testing.T) { +func TestSkillsShellNode_MissingMarkerFails(t *testing.T) { managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} managed.Name = "agent" - g := &promptGraph{managed: managed, bindings: map[string]any{}} - fake := &fakeSkillAttacher{err: errors.New("publish failed")} + g := &promptGraph{managed: managed, bindings: map[string]any{}, env: map[string]string{}} skills := []skillBundle{ {Dir: "a", Meta: skillMeta{Name: "skill-a", Description: "d", Instructions: "do a"}}, } - node := skillsShellNode(g, skills, nil, func() (skillAttacher, error) { return fake, nil }) + node := skillsShellNode(g, skills, nil) err := node.Resolve(context.Background()) if err == nil { - t.Fatal("expected the attacher error to propagate") + t.Fatal("expected an unpublished skill to fail the deploy") } if len(managed.Skills) != 0 || len(managed.Tools) != 0 { t.Errorf("definition must be left untouched on failure: skills=%v tools=%v", diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go index 6200ab22a6d..c43164ec3b8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go @@ -138,6 +138,108 @@ func expandPromptAgentSettings( return &expanded, nil } +// expandPromptAgentPolicies resolves ${VAR} references in the agent's +// policies[].raiPolicyName against the azd environment. +// +// A Responsible AI policy is addressed by its full ARM resource ID, which +// embeds a subscription, resource group and account. `azd ai agent init` writes +// ${RAI_POLICY_ID} rather than that ID so the scaffold can be copied to another +// subscription unchanged, and the generated `rai` infrastructure layer exports +// the concrete value at provision time. +// +// An unresolved reference is fatal rather than silently empty: dropping the +// policy would publish an agent without the guardrails its manifest declares. +func expandPromptAgentPolicies(managed *agent_yaml.PromptAgent, env map[string]string) error { + lookup := func(name string) string { + if value, ok := env[name]; ok { + return value + } + value, _ := os.LookupEnv(name) + return value + } + + for i := range managed.Policies { + policy := &managed.Policies[i] + if policy.Type != agent_yaml.PolicyTypeRai { + continue + } + raw := strings.TrimSpace(policy.RaiPolicyName) + if raw == "" { + continue + } + expanded, err := ExpandEnv(raw, lookup) + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("failed to expand policies[%d].raiPolicyName: %s", i, err), + "check the ${VAR} references in the policies block in agent.yaml", + ) + } + expanded = strings.TrimSpace(expanded) + if expanded == "" { + return exterrors.Dependency( + exterrors.CodeRaiPolicyNotFound, + fmt.Sprintf("policies[%d].raiPolicyName is %q, but that value is not set in the azd environment", + i, raw), + "run `azd provision` to create the policy declared by your infrastructure, or set the "+ + "variable to the policy's full ARM resource ID with `azd env set "+ + raiPolicyEnvVarName+" `", + ) + } + policy.RaiPolicyName = expanded + } + return nil +} + +// raiPolicyEnvVarName is the variable `azd ai agent init` records the resolved +// Responsible AI policy ID under. Named here so the deploy-time suggestion +// above points at the same variable the scaffold writes. +const raiPolicyEnvVarName = "RAI_POLICY_ID" + +// promptCreateError converts a failed agent create into an actionable error. +// +// Guardrails get a suggestion of their own. The managed harness has been +// observed to reject rai_config outright while the same policy is accepted by a +// plain prompt agent, and the service returns a generic bad request that names +// neither rai_config nor the policy. Without this, the only visible difference +// between "your policy is wrong" and "this harness does not take policies yet" +// is a message that mentions neither. +func promptCreateError(err error, managed *agent_yaml.PromptAgent) error { + converted := exterrors.ServiceFromAzure(err, exterrors.OpCreateAgent) + + local, ok := errors.AsType[*azdext.LocalError](converted) + if !ok || !declaresRaiPolicy(managed) { + return converted + } + + suggestion := "This agent declares a Responsible AI policy. Verify the policy ID is correct and " + + "reachable from this account, then re-run. If the policy is valid, the harness may not accept " + + "policies yet — remove the policies block from agent.yaml to confirm, and deploy without " + + "'harness:' to apply the policy as a plain prompt agent." + if managed.HarnessType() == "" { + suggestion = "This agent declares a Responsible AI policy. Verify the policy ID is correct and " + + "reachable from this account, then re-run." + } + if local.Suggestion != "" { + suggestion = local.Suggestion + " " + suggestion + } + local.Suggestion = suggestion + return local +} + +// declaresRaiPolicy reports whether the agent binds a Responsible AI policy. +func declaresRaiPolicy(managed *agent_yaml.PromptAgent) bool { + if managed == nil { + return false + } + for _, policy := range managed.Policies { + if policy.Type == agent_yaml.PolicyTypeRai && strings.TrimSpace(policy.RaiPolicyName) != "" { + return true + } + } + return false +} + // resolvedPromptAgentSettings returns the prompt-agent settings with the same // azd environment-derived target resolution deployPromptAgent applies. Read-only // callers (Endpoints, GetTargetResource) must use this rather than @@ -299,6 +401,13 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( return nil, err } + // Guardrails are stated as ${RAI_POLICY_ID} so the project stays portable; + // resolve them against the azd environment before anything validates the + // shape of the value. + if err := expandPromptAgentPolicies(&managed, env); err != nil { + return nil, err + } + // Overlay the provisioned Foundry project values from the azd environment // onto any settings still at their default placeholder. This makes the // "create a new Foundry project" init path work: `azd up` provisions the @@ -390,7 +499,7 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( } } if err != nil { - return nil, exterrors.ServiceFromAzure(err, exterrors.OpCreateAgent) + return nil, promptCreateError(err, &managed) } latest := agent.Versions.Latest diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go index ad4391e8934..cbec2c695ee 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -516,10 +516,18 @@ func ProjectEndpoint( } // expandEndpoint resolves ${VAR} in a project service's endpoint: and trims the -// result. Unset variables expand to the empty string, so a fully unresolved -// endpoint is indistinguishable from an absent one. +// result. Values come from env first, then the process environment. Unset +// variables expand to the empty string, so a fully unresolved endpoint is +// indistinguishable from an absent one. func expandEndpoint(raw string, env map[string]string) (string, error) { - expanded, err := maybeExpand(strings.TrimSpace(raw), env, true) + mapping := func(name string) string { + if value, found := env[name]; found { + return value + } + value, _ := os.LookupEnv(name) + return value + } + expanded, err := maybeExpand(strings.TrimSpace(raw), mapping, true) if err != nil { return "", fmt.Errorf("expand endpoint: %w", err) } diff --git a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json index 553e7692fd2..dbe69164dc3 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json @@ -4,6 +4,45 @@ "description": "Custom configuration for the Azure AI Agent Service target", "type": "object", "properties": { + "promptAgent": { + "type": "object", + "description": "Marks the service as a Prompt-family (prompt, prompt-voice, managed) agent and tells azd how to reach the harness that runs it. `azd ai agent init` writes every field as a ${VAR} reference so azure.yaml carries no tenant-specific values.", + "properties": { + "baseUrl": { + "type": "string", + "description": "Harness origin (scheme + host, optionally port)." + }, + "subscriptionId": { + "type": "string", + "description": "Azure subscription containing the Foundry workspace." + }, + "resourceGroup": { + "type": "string", + "description": "Azure resource group containing the Foundry workspace." + }, + "workspace": { + "type": "string", + "description": "Foundry (Azure ML) workspace name." + }, + "projectEndpoint": { + "type": "string", + "description": "Foundry project data-plane root. When set it is the authoritative routing target for all prompt agent operations and supersedes the workspace tuple." + }, + "apiVersion": { + "type": "string", + "description": "api-version query parameter sent on every request. Defaults to the extension's pinned version when omitted." + }, + "modelEndpoint": { + "type": "string", + "description": "Model gateway the harness calls to reach the LLM." + } + }, + "additionalProperties": false + }, + "$ref": { + "type": "string", + "description": "Path to the file carrying this agent's definition, relative to the service's project directory. Defaults to agent.yaml when omitted." + }, "container": { "$ref": "#/definitions/ContainerSettings" }, diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go index ad4391e8934..cbec2c695ee 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -516,10 +516,18 @@ func ProjectEndpoint( } // expandEndpoint resolves ${VAR} in a project service's endpoint: and trims the -// result. Unset variables expand to the empty string, so a fully unresolved -// endpoint is indistinguishable from an absent one. +// result. Values come from env first, then the process environment. Unset +// variables expand to the empty string, so a fully unresolved endpoint is +// indistinguishable from an absent one. func expandEndpoint(raw string, env map[string]string) (string, error) { - expanded, err := maybeExpand(strings.TrimSpace(raw), env, true) + mapping := func(name string) string { + if value, found := env[name]; found { + return value + } + value, _ := os.LookupEnv(name) + return value + } + expanded, err := maybeExpand(strings.TrimSpace(raw), mapping, true) if err != nil { return "", fmt.Errorf("expand endpoint: %w", err) } From 70561f52c8f24a19dbb4f4a28e1988359977464a Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Wed, 26 Aug 2026 21:50:53 +0530 Subject: [PATCH 20/24] docs(ai-agents): record RAI policy binding and sibling-service changes Bring CHANGELOG.md and README.md in line with what the branch actually ships. The entries were written against the behavior in the code, not against the design notes, so each one names the failure it replaces rather than only the feature it adds. CHANGELOG: - --kind managed is rejected in favor of --kind prompt with a harness, the preview notice now fires on every init path, and the harnessed flavor gets its own default agent name so two inits in one folder no longer collide on the Foundry agent identity. - RAI policy selection during init, the RAI_POLICY_ID indirection that keeps a scaffold portable across subscriptions, and the best-effort pre-publish verification that names the policy and the account. - skills/ folders now become azure.ai.skill services owned by the skills extension; this extension only attaches the published version. - connections and toolbox references become sibling Foundry services so provision and deploy order live in azure.yaml. - azure.ai.routine is recognized in uses: with the dependency running routine to agent. - The harness type spelling is github_copilot_preview throughout. README: point at the init-time policy picker and --rai-policy from the RAI section, which previously implied hand-editing was the only path. --- .../extensions/azure.ai.agents/CHANGELOG.md | 22 +++++++++++++++---- cli/azd/extensions/azure.ai.agents/README.md | 4 +++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 18abb5fe256..f2f3890ac2b 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +- **Breaking:** `--kind managed` is no longer accepted, because "managed" was never a kind. A managed agent is a prompt agent that names an execution harness — both scaffold `kind: prompt`, and the harness is the only difference — so it is now spelled `--kind prompt --harness github_copilot_preview`. Passing the old value fails with that replacement rather than a bare "unknown value". The interactive picker is unchanged: choosing "Prompt agent with GitHub Copilot harness" still selects the harnessed flavor in one keystroke, it just no longer routes through a kind that nothing downstream understood. +- The prompt-agent preview notice now appears on every path into `azd ai agent init`, not only the interactive picker. `--kind prompt` and manifest adoption previously scaffolded a preview feature with no indication it was one. +- `azd ai agent init` now suggests a different default agent name for the harnessed flavor (`my-copilot-agent`) than for the plain one (`my-prompt-agent`). Accepting the default for both in the same folder previously produced one project overwriting the other, and — because the name is the Foundry agent identity — a second `azd up` silently versioned the first agent instead of creating a second one. + +- Prompt and managed agents can now be bound to a Responsible AI policy during `azd ai agent init`, instead of the policy being something you hand-write into `agent.yaml` afterwards. Init lists the policies already on the selected Foundry account and lets you pick one; `--rai-policy` takes `none`, a policy name, or a full ARM resource ID for scripted runs. With `--no-prompt` and no flag, nothing is attached, and a `--manifest` that already declares `policies:` is never prompted over. `azd` attaches an existing policy — creating one stays with whoever owns the account, and the docs carry a worked `az` and Bicep example. + - The scaffold writes `raiPolicyName: ${RAI_POLICY_ID}` rather than the resource ID itself, and records the concrete ID in the azd environment, so a project can be copied to another subscription and deployed unchanged. `azd deploy` now expands `${VAR}` references in `policies[].raiPolicyName`; an unresolved reference fails naming the variable instead of silently publishing an agent without the guardrails its manifest declares. + - Before publishing, `azd deploy` verifies the policy exists on the target account and names the policy and the account when it does not — the create call reports a missing policy as a generic bad request that mentions neither. Verification is best-effort: a missing read permission produces a warning rather than blocking a deploy the service would have accepted. + - A create rejected on an agent that declares a policy now says so, and distinguishes "the policy is wrong" from "this harness does not accept policies yet". + +- **Breaking:** a `skills/` folder is now created by the `azure.ai.skills` extension rather than by this one. Creating and versioning a Foundry skill belongs to whoever owns `host: azure.ai.skill`, so `azd ai agent init` now writes one `azure.ai.skill` service per `skills//` folder — with `archive:` pointing at the folder so the scripts and references a skill needs travel with its instructions — and lists it in the agent's `uses:`. At deploy time this extension only *attaches* the version that service published, read from the `SKILL__VERSION` marker it records in the azd environment. A bundle with no such service now fails the deploy naming the `azure.yaml` entry to add, instead of azd quietly uploading a second copy of the skill under its own lifecycle. Existing projects: re-run `azd ai agent init` to have the entries written for you, then `azd deploy --all`. +- Prompt and managed agents now emit the same Foundry sibling services hosted agents already did. A `connections:` block in `agent.yaml` becomes one `azure.ai.connection` service per connection, and a `toolbox:` reference is added to the agent's `uses:` when a toolbox service of that name exists, so provisioning and deploy order are expressed in `azure.yaml` rather than implied. +- Deploy now prefers what the sibling services published over what it can infer on its own. A toolbox's MCP endpoint is taken from the `TOOLBOX__MCP_ENDPOINT` marker the `azure.ai.toolboxes` extension records, instead of being synthesized from the toolbox name, and a connection listed in `AZURE_AI_PROJECT_CONNECTION_NAMES` is used as-is instead of being re-created — the data-plane listing can lag a connection that was just provisioned. Both markers are checked against the project they were recorded for, so one left over from another Foundry project fails the deploy rather than pointing the agent somewhere it cannot reach. Toolboxes with no sibling service keep working through the previous lookup. +- `azure.ai.routine` is now recognized in a `uses:` list. A routine names the agent it dispatches, so the dependency runs routine → agent and there is nothing for the agent to wait on; previously the host fell through to the generic case and produced a misleading "provision the dependency first" suggestion. + - **Breaking:** agents that name a harness now reject fields and tool types the harness cannot honor, matching the Foundry GitHub Copilot harness spec. The service fails these at the API rather than ignoring them, so azd now catches them at deploy time and names the offending key: - `temperature`, `top_p`, `tool_choice` and `text` are rejected — the harness supplies its own sampling parameters and response format. - `reasoning` accepts only `effort`; any other property is rejected. @@ -10,7 +24,7 @@ None of this narrows what a **harness-less** prompt agent accepts — every field and tool type above still works without `harness:`. The rejection lists are authoritative (taken from the spec), but a tool type absent from them is still passed through, so types newer than your azd build continue to deploy. - `reminder_preview`, `toolbox_search` and `web_iq_preview` are now recognized tool types, so declaring one no longer produces a spurious "unrecognized tool type" warning. - **Breaking:** `harness:` in `agent.yaml` is now a block rather than a bare string, matching the managed-agent API: `harness:` with a required `type`, plus optional `skills`, `environment` (`cpu`/`memory`/`idle_timeout_seconds`) and `builtin_tools` (`allowed`/`excluded`). `cpu` and `memory` must be set together, and `builtin_tools` entries are checked against the harness capabilities (`filesystem_read`, `filesystem_write`, `shell`, `subagents`, `web`) so a typo fails locally instead of silently widening what the agent can do. A string value is rejected with the replacement block in the error text. -- **Breaking:** the managed-agent harness type is now spelled `github-copilot` in `agent.yaml` and on `--harness` (was `ghcp`). The old abbreviation is rejected with an error naming the replacement rather than being silently upgraded, so a manifest never disagrees with what is sent to the service. Update `harness: ghcp` to a `harness:` block with `type: github-copilot`. +- **Breaking:** the managed-agent harness type is now spelled `github_copilot_preview` in `agent.yaml` and on `--harness` (was `ghcp`). The old abbreviation is rejected with an error naming the replacement rather than being silently upgraded, so a manifest never disagrees with what is sent to the service. Update `harness: ghcp` to a `harness:` block with `type: github_copilot_preview`. - The link from an `azure.yaml` service to its agent definition file is now explicit, using the same `$ref` file-include directive every other Foundry resource already uses: `$ref: ./agent.yaml` on the service entry. The referenced file's contents are merged onto the service entry, and a declared file that does not exist is a hard error rather than a silent fallback to the `agent.yaml`/`agent.yml` convention. `AGENT_DEFINITION_PATH` still wins over everything. - `azd ai agent init` now writes that reference out instead of leaving it to convention: `$ref: ./agent.yaml` on the service entry in `azure.yaml`. Behavior is unchanged for projects that omit it — the convention still applies — but the scaffold now shows the `azure.yaml` → `agent.yaml` edge in the files themselves, so the file can be renamed by editing one line. - Prompt (kind: prompt) agents now support a convention-over-configuration deploy pipeline. `azd up` resolves an internal dependency graph before publishing the agent and validates the whole graph first so a failure never leaves a half-wired agent: @@ -20,7 +34,7 @@ - A `connections:` block resolves through a precedence ladder (use existing, create-if-missing with Entra default, auto-fill target from provisioning outputs, or provision/fail-fast), and each tool's required role is surfaced for assignment. - The model deployment is create-if-missing, and container-only fields (`image`, `protocols`, `code_configuration`, …) are rejected for prompt agents. - The manifest parser recognizes `skill` and `file` resource kinds. -- Prompt agents now support **memory** via a new `memory:` block in `agent.yaml`. `azd` creates the named Foundry memory store if it does not exist (reusing it if it does) and appends a `memory_search_preview` tool bound to it, since the prompt-agent API has no memory field of its own. `scope` defaults to `{{$userId}}` so a shared agent cannot surface one user's memories in another user's conversation. Available on managed agents (`harness: github-copilot`) too; a switch (`harnessedPromptFeatures` in `internal/pkg/agents/agent_yaml/prompt_features.go`) can fail the deploy fast if a harness is ever confirmed to ignore a capability. +- Prompt agents now support **memory** via a new `memory:` block in `agent.yaml`. `azd` creates the named Foundry memory store if it does not exist (reusing it if it does) and appends a `memory_search_preview` tool bound to it, since the prompt-agent API has no memory field of its own. `scope` defaults to `{{$userId}}` so a shared agent cannot surface one user's memories in another user's conversation. Available on managed agents (`harness: github_copilot_preview`) too; a switch (`harnessedPromptFeatures` in `internal/pkg/agents/agent_yaml/prompt_features.go`) can fail the deploy fast if a harness is ever confirmed to ignore a capability. - Documented that the portal's **guardrails** and **knowledge** capabilities are already supported through existing keys — `policies:` (a `rai_policy` entry becomes the definition's `rai_config`) and the `vector-assets/` folder plus retrieval entries in `tools:` respectively. Neither is a field on the prompt-agent API, so no new keys were added. - Prompt agents now support the `temperature:`, `top_p:`, `text:`, and `reasoning:` keys in `agent.yaml`, which previously had no binding and so could not be set at all. `temperature` and `top_p` are nullable, so an explicit `temperature: 0` is sent as `0` rather than collapsing into "unset" and picking up the service default. Together with the existing keys, all eleven fields the prompt-agent API's definition accepts are now reachable from `agent.yaml`. - `tools:` entries are now validated. The service ignores a tool whose `type` it cannot identify **without reporting an error**, so a typo previously deployed "successfully" and produced an agent silently missing a capability its manifest claimed. Entries that are unambiguously malformed — not a mapping, no `type`, a non-string or blank `type`, or a type the API has removed (`memory_search`, replaced by `memory_search_preview`) — now fail validation before anything is provisioned, naming the offending index. A merely *unrecognized* type is reported as a warning and still deployed, since it may be newer than your azd build; hard-failing would make every new service tool type a breaking change. @@ -40,8 +54,8 @@ - The `config.promptAgent` block is now written entirely as environment references — `baseUrl: ${AZD_MANAGED_AGENT_BASE_URL}`, `subscriptionId: ${AZURE_SUBSCRIPTION_ID}`, `resourceGroup: ${AZURE_RESOURCE_GROUP}`, `workspace: ${AZURE_AI_WORKSPACE}`, and `projectEndpoint: ${AZURE_AI_PROJECT_ENDPOINT}` — instead of the resolved literals. The references are expanded against the azd environment at deploy time, and a reference whose variable is unset falls back to the built-in default, so a block that cannot be resolved no longer blocks deploy. `init` writes `AZURE_AI_WORKSPACE` into the azd environment alongside `AZURE_AI_PROJECT_ENDPOINT`. Blocks containing literal values keep working unchanged. - `azd ai agent init` now offers a plain **prompt agent** alongside the harnessed one. Both scaffold `kind: prompt`; the difference is the new optional `harness` field in `agent.yaml`: - *Prompt agent (no code, Foundry-managed)* — omits `harness`. Foundry runs the model, instructions, and tools directly; there is no Brain+Hand sandbox to provision. - - *Prompt agent with GitHub Copilot harness (preview)* — writes `harness: github-copilot`, the previous behavior. - Non-interactively, use `--kind prompt` or `--kind managed`; `--harness github-copilot|none` overrides the harness implied by the kind. Previously every prompt agent was published with a hard-coded harness, and the field was never written to the scaffolded `agent.yaml`. + - *Prompt agent with GitHub Copilot harness (preview)* — writes `harness: github_copilot_preview`, the previous behavior. + Non-interactively, `--kind prompt` scaffolds the plain flavor and `--harness github_copilot_preview` adds the harness. Previously every prompt agent was published with a hard-coded harness, and the field was never written to the scaffolded `agent.yaml`. - **Breaking:** a prompt agent that names a `harness:` may no longer declare `memory:` or any knowledge/grounding tool (`file_search`, `azure_ai_search`, `bing_grounding`, `sharepoint_grounding_preview`, and the other retrieval types, plus the `file_search` entry azd synthesizes from a `vector-assets/` folder). The harness spec documents RAI policy attachment but puts grounding out of scope and never describes memory, so these are now rejected at deploy time with a message naming the capability, instead of being published and silently dropped. `policies:` (guardrails) is unaffected, and a prompt agent without `harness:` still supports all three. Move an agent that needs memory or its own corpus off the harness by removing the `harness:` key. - **Breaking:** the `agent.yaml` discriminator for prompt agents is now `kind: prompt` (was `kind: managed`). Existing `agent.yaml` files must be updated; the scaffolded schema annotation now points at `PromptAgent.yaml`. The `--kind managed` init flag value is still accepted, and now selects the GitHub Copilot-harnessed prompt agent. - **Breaking:** removed `connections[].provision` from `agent.yaml`. The field was reserved but never implemented, and setting it always failed the deploy — a declaration carries only a name, auth type, and metadata, with no resource kind, SKU, or region to create anything from, and creating resources belongs to `azd provision` rather than `azd deploy`. A connection that matches no existing connection and has no resolvable `target` now fails with a single message telling you to provision the resource with infrastructure and set `connections[].target`. Remove the key from any manifest that sets it; nothing else changes. diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index f6c5760caf0..9ac66a15a27 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -190,7 +190,9 @@ Details: `Microsoft.DefaultV2` still need the full ID, with the account that hosts them in the path. - Create or list policies on the Foundry account first — azd does not create the - policy, it only associates the agent with an existing one. + policy, it only associates the agent with an existing one. For prompt and + managed agents, `azd ai agent init` lists the policies on the selected account + and can bind one for you; see `--rai-policy`. > **Note:** In the deprecated on-disk `agent.yaml` shape the key is snake_case > (`rai_policy_name`). In `azure.yaml` it is camelCase (`raiPolicyName`), like From b59d5907c07df3f672e084834590cbf435dc56ca Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Wed, 26 Aug 2026 21:50:54 +0530 Subject: [PATCH 21/24] fix(ai-agents): repair typed-nil credential fallback and clear lint debt A branch-wide sweep with deadcode, staticcheck, golangci-lint and cspell turned up two findings introduced by this branch. One was a latent bug. service_target_prompt.go: resolvePromptWorkspaceFromAzure assigned p.credential, a concrete *azidentity.AzureDeveloperCLICredential, into an azcore.TokenCredential and then compared that interface to nil. Prompt agents skip the hosted credential-init path, so p.credential is always nil there -- but a nil concrete pointer boxed into an interface yields an interface that is non-nil while carrying a nil pointer. The comparison never succeeded, the promptCredential() fallback never ran, and a typed-nil credential was handed to armresources.NewClient. The nil check now happens on the concrete pointer before the assignment, so workspace discovery actually falls back instead of relying on the deferred recover above it to swallow the result. No other assignment into azcore.TokenCredential has the same shape. deploy.go: the deprecated deploy command reassigned extCtx from ensureExtensionContext and never read it. ensureExtensionContext is pure, so the call was dead. The parameter is kept for symmetry with the other constructors in root.go and the doc comment now says why it is unused. resource_services_test.go: gosec G301 -- the test bundle directory was created 0o755 and is now 0o750. cspell.yaml: allow the fifteen terms the branch introduces (prompt-agent graph, skills, connection and policy vocabulary) so the extension spell check passes. samples_test.go is removed. It walked a samples/ tree at the extension root that was never committed, so it would have failed in CI on every run while passing locally off untracked files. Verified clean on all three touched modules: gofmt -s, go fix -diff, go build, go test, golangci-lint v2.11.4, staticcheck 2026.1, cspell 8.13.1 and the copyright header check. --- .../extensions/azure.ai.agents/cspell.yaml | 16 +++ .../azure.ai.agents/internal/cmd/deploy.go | 8 +- .../internal/cmd/resource_services_test.go | 2 +- .../pkg/agents/agent_yaml/samples_test.go | 116 ------------------ .../internal/project/service_target_prompt.go | 10 +- 5 files changed, 31 insertions(+), 121 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/samples_test.go diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 83196175fe5..640dc4c1f01 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -133,3 +133,19 @@ words: - cognitiveservices - fdp - PES + # Prompt agent (graph, skills, connections, policies) terms + - chdirs + - dedupe + - frontmatter + - gerr + - ghcp + - pasteable + - pctx + - raipolicies + - retarget + - sandboxed + - stringifying + - subfolders + - ufeff + - warnf + - workspacenotfound diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go index fcefec0e2a0..23698ef9499 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go @@ -20,9 +20,11 @@ import ( // the harness by the service-target provider during `azd up` / `azd deploy`, // exactly like hosted agents. The previous standalone harness-deploy behavior // has been removed in favor of that unified flow. -func newDeployCommand(extCtx *azdext.ExtensionContext) *cobra.Command { - extCtx = ensureExtensionContext(extCtx) - +// +// The extension context is accepted for symmetry with the other command +// constructors in root.go but is unused: the command only returns an error +// pointing at the standard lifecycle. +func newDeployCommand(_ *azdext.ExtensionContext) *cobra.Command { cmd := &cobra.Command{ Use: "deploy [name]", Short: "Deprecated: use `azd up` or `azd deploy`.", diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go index c77050e1e5a..ce5fbf73534 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -699,7 +699,7 @@ func TestPromptResourceServices(t *testing.T) { dir := t.TempDir() bundle := filepath.Join(dir, "skills", "code-review") - require.NoError(t, os.MkdirAll(bundle, 0o755)) + require.NoError(t, os.MkdirAll(bundle, 0o750)) require.NoError(t, os.WriteFile(filepath.Join(bundle, "SKILL.md"), []byte( "---\nname: code-review\ndescription: reviews code\n---\n\nDo the review.\n", ), 0o600)) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/samples_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/samples_test.go deleted file mode 100644 index 918eb86865c..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/samples_test.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package agent_yaml - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/require" - "go.yaml.in/yaml/v3" -) - -// samplesDir is the authored-examples tree at the extension root. -const samplesDir = "../../../../samples" - -// TestSamples_Parse keeps the hand-authored samples honest. -// -// The samples exist to be copied, so a key that silently fails to bind is worse -// than a broken build: it teaches the wrong schema. Decoding with KnownFields -// turns any typo, stale key, or invented field in samples/**/agent.yaml into a -// test failure here rather than into a deployed agent that quietly ignores it. -func TestSamples_Parse(t *testing.T) { - t.Parallel() - - manifests := findSampleManifests(t) - require.NotEmpty(t, manifests, "no sample agent.yaml files found under %s", samplesDir) - - for _, manifest := range manifests { - name, err := filepath.Rel(samplesDir, manifest) - require.NoError(t, err) - - t.Run(filepath.ToSlash(name), func(t *testing.T) { - t.Parallel() - - content, err := os.ReadFile(manifest) - require.NoError(t, err) - - decoder := yaml.NewDecoder(strings.NewReader(string(content))) - decoder.KnownFields(true) - - var agent PromptAgent - require.NoError(t, decoder.Decode(&agent), "sample declares a key PromptAgent does not bind") - - require.Equal(t, AgentKindPrompt, agent.Kind, "samples are all prompt agents") - require.NotEmpty(t, agent.Name) - require.NotEmpty(t, agent.Model) - - // Instructions are declared inline, matching the prompt-agent API - // schema. A sample that drops them would teach a shape the service - // rejects. - require.NotEmpty(t, agent.Instructions, "samples declare instructions inline") - - assertSampleMemoryIsDeployable(t, agent.Memory) - }) - } -} - -// TestSamples_BuildAPIRequest runs the samples through the same mapping the -// deploy path uses, so a sample cannot pass parsing yet fail at deploy time. -func TestSamples_BuildAPIRequest(t *testing.T) { - t.Parallel() - - for _, manifest := range findSampleManifests(t) { - name, err := filepath.Rel(samplesDir, manifest) - require.NoError(t, err) - - t.Run(filepath.ToSlash(name), func(t *testing.T) { - t.Parallel() - - content, err := os.ReadFile(manifest) - require.NoError(t, err) - - var agent PromptAgent - require.NoError(t, yaml.Unmarshal(content, &agent)) - - request, err := CreatePromptAgentAPIRequest(agent, nil) - require.NoError(t, err) - require.Equal(t, agent.Name, request.Name) - }) - } -} - -// assertSampleMemoryIsDeployable mirrors the memory validation the deploy graph -// performs, which lives in the project package and so cannot be called here. -func assertSampleMemoryIsDeployable(t *testing.T, memory *PromptMemory) { - t.Helper() - - if memory == nil { - return - } - - require.NotEmpty(t, memory.Store, "memory requires a store name") - require.NotEmpty(t, memory.ChatModel, "memory requires a chat model") - require.NotEmpty(t, memory.EmbeddingModel, "memory requires an embedding model") -} - -func findSampleManifests(t *testing.T) []string { - t.Helper() - - var manifests []string - err := filepath.WalkDir(samplesDir, func(path string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - if !entry.IsDir() && entry.Name() == "agent.yaml" { - manifests = append(manifests, path) - } - return nil - }) - require.NoError(t, err) - - return manifests -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go index c43164ec3b8..bbaf9fdf6d2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go @@ -818,7 +818,15 @@ func (p *AgentServiceTargetProvider) resolvePromptWorkspaceFromAzure( // Prompt agents skip the hosted credential-init path so p.credential is nil. // Fall back to the prompt harness credential so workspace discovery works. - var cred azcore.TokenCredential = p.credential + // + // The nil check is on the concrete pointer, not on the interface. Assigning a + // nil *AzureDeveloperCLICredential into azcore.TokenCredential produces an + // interface that is non-nil but carries a nil pointer, so comparing the + // interface to nil never succeeds and the fallback below never runs. + var cred azcore.TokenCredential + if p.credential != nil { + cred = p.credential + } if cred == nil { cred = promptCredential() } From c5980a99b60aef8d2b5b7dad0e564c23e6749574 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Wed, 26 Aug 2026 23:08:03 +0530 Subject: [PATCH 22/24] fix(ai-agents): reject silent typos in authored agent.yaml blocks Three related parse gaps in the prompt agent manifest, all with the same shape: an author writes something wrong and azd deploys anyway. `harness:` changed from a bare string to a block during this work, but nothing handled the old form. An author carrying a manifest forward got go-yaml's "cannot unmarshal !!str into agent_yaml.PromptHarness", which names a Go type and no fix. It now reports the block to write, and upgrades `ghcp` to `github_copilot_preview` in that suggestion so the two changes are fixed in one pass rather than one after the other. `harness.type: ghcp` in the block form was forwarded verbatim, so the rename was only enforced on the `--harness` flag -- the fourth instance on this branch of the interactive path validating and the declarative path not. Only the name azd itself renamed is rejected; an unrecognized harness still passes through, which is the documented reason there is no allowlist. The `harness:` and `memory:` blocks were decoded non-strictly, so `builtin_tool:` for `builtin_tools:` bound nothing and left every built-in capability enabled. Both now reject unknown keys. This is scoped to the blocks azd interprets, not applied at the top level: validateAgentServiceDefinition passes the whole azure.yaml service entry -- host, project, uses, config -- through the same validator, so a top-level strict decode would reject every prompt agent. Tools stay `[]any` and are unaffected, so a tool type newer than this build still deploys. Also drops the panic recovery in resolvePromptWorkspaceFromAzure. It converted a crash into ("", false), which the caller cannot tell apart from "no workspace exists", so a panic silently became a request to provision a new workspace -- that is what hid the typed-nil credential bug fixed in b59d5907c. deployPromptAgent already recovers at the RPC boundary and reports it as a deploy error. Guards two prompt responses that were dereferenced unconditionally, matching the form already used in delete.go and eval_helpers.go. --- .../cmd/init_from_templates_helpers.go | 8 +- .../internal/cmd/init_rai_policy.go | 7 + .../agent_yaml/prompt_strict_yaml_test.go | 189 ++++++++++++++++++ .../internal/pkg/agents/agent_yaml/yaml.go | 125 ++++++++++++ .../internal/project/service_target_prompt.go | 11 +- 5 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_strict_yaml_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go index 62647309cfb..9506f9f88b4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go @@ -241,8 +241,12 @@ func promptAgentKind( } // Two menu rows share the value "prompt", so the answer is resolved by - // index. Guard it: an out-of-range index would otherwise pick a harness at - // random or panic. + // index. Guard it: a missing or out-of-range index would otherwise pick a + // harness at random or panic. + if resp == nil || resp.Value == nil { + return "", "", fmt.Errorf("agent kind selection returned no value") + } + selected := int(*resp.Value) if selected < 0 || selected >= len(agentKindMenu) { return "", "", fmt.Errorf("agent kind selection returned an out-of-range index %d", selected) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy.go index ce24677c4c5..b8f7ff84cca 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy.go @@ -203,6 +203,13 @@ func promptForRaiPolicy( ) } + // A prompt that reports no error but carries no index means the harness + // returned nothing to choose from. Treat it as "attach no policy" rather + // than dereferencing a nil pointer. + if resp == nil || resp.Value == nil { + return raiPolicySelection{}, nil + } + selected := int(*resp.Value) if selected <= 0 || selected > len(existing) { return raiPolicySelection{}, nil diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_strict_yaml_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_strict_yaml_test.go new file mode 100644 index 00000000000..448af987d87 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_strict_yaml_test.go @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" +) + +// TestPromptHarness_RejectsStringForm pins the migration message for the +// breaking change from `harness: ` to a `harness:` block. +// +// The value of this change is entirely in the error text: without it go-yaml +// reports "cannot unmarshal !!str into agent_yaml.PromptHarness", which names a +// Go type and gives an author nothing to act on. +func TestPromptHarness_RejectsStringForm(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yaml string + wantInError []string + }{ + { + name: "current spelling", + yaml: "kind: prompt\nname: a\nmodel: m\nharness: github_copilot_preview\n", + wantInError: []string{ + "harness must be a block, not a string", + "type: github_copilot_preview", + }, + }, + { + // The obsolete abbreviation and the string form usually appear + // together, since both come from the same older sample. The message + // has to fix both at once or the author fixes one and hits the other. + name: "obsolete abbreviation is upgraded in the suggested block", + yaml: "kind: prompt\nname: a\nmodel: m\nharness: ghcp\n", + wantInError: []string{ + "harness must be a block, not a string", + "type: github_copilot_preview", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var agent PromptAgent + err := yaml.Unmarshal([]byte(tt.yaml), &agent) + require.Error(t, err) + for _, want := range tt.wantInError { + require.Contains(t, err.Error(), want) + } + }) + } +} + +// TestPromptHarness_RejectsObsoleteType covers the block form carrying the old +// abbreviation. azd deliberately keeps no allowlist of harness names so a +// harness added by the service needs no azd release, which means an unknown +// name must still pass through -- only the name azd itself renamed is rejected. +func TestPromptHarness_RejectsObsoleteType(t *testing.T) { + t.Parallel() + + var agent PromptAgent + err := yaml.Unmarshal([]byte("kind: prompt\nname: a\nmodel: m\nharness:\n type: ghcp\n"), &agent) + require.Error(t, err) + require.Contains(t, err.Error(), `harness.type "ghcp" is no longer accepted`) + require.Contains(t, err.Error(), "github_copilot_preview") +} + +// TestPromptHarness_UnknownHarnessTypePassesThrough is the negative of the test +// above: a name azd has never heard of is forwarded, not rejected. +func TestPromptHarness_UnknownHarnessTypePassesThrough(t *testing.T) { + t.Parallel() + + var agent PromptAgent + require.NoError(t, yaml.Unmarshal( + []byte("kind: prompt\nname: a\nmodel: m\nharness:\n type: some_future_harness\n"), &agent)) + require.NotNil(t, agent.Harness) + require.Equal(t, "some_future_harness", agent.Harness.Type) +} + +// TestPromptAgent_RejectsUnknownKeysInAuthoredBlocks covers the blocks azd acts +// on rather than forwards. A key that binds to nothing in one of these deploys +// an agent that differs from its manifest with nothing in the output to say so +// -- `builtin_tool:` for `builtin_tools:` leaves every built-in capability on. +func TestPromptAgent_RejectsUnknownKeysInAuthoredBlocks(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yaml string + wantKey string + wantHint string + }{ + { + name: "harness typo", + yaml: "kind: prompt\nname: a\nmodel: m\n" + + "harness:\n type: github_copilot_preview\n builtin_tool:\n allowed: []\n", + wantKey: "builtin_tool", + wantHint: "harness:", + }, + { + name: "nested environment typo", + yaml: "kind: prompt\nname: a\nmodel: m\n" + + "harness:\n type: github_copilot_preview\n environment:\n cpus: \"1\"\n", + wantKey: "cpus", + wantHint: "harness:", + }, + { + name: "memory typo", + yaml: "kind: prompt\nname: a\nmodel: m\n" + + "memory:\n store: s\n chat_modell: gpt-4.1-mini\n", + wantKey: "chat_modell", + wantHint: "memory:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var agent PromptAgent + err := yaml.Unmarshal([]byte(tt.yaml), &agent) + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantKey) + require.Contains(t, err.Error(), tt.wantHint) + }) + } +} + +// TestPromptAgent_ToolsStayForwardCompatible guards the boundary of the strict +// decoding above. Tools are passed to the service verbatim, so a tool type or +// property newer than this build must keep deploying -- strictness applies to +// the blocks azd interprets, not to the ones it forwards. +func TestPromptAgent_ToolsStayForwardCompatible(t *testing.T) { + t.Parallel() + + const manifest = `kind: prompt +name: a +model: m +harness: + type: github_copilot_preview +tools: + - type: some_tool_invented_next_year + some_property_azd_has_never_seen: true +` + + var agent PromptAgent + require.NoError(t, yaml.Unmarshal([]byte(manifest), &agent)) + require.Len(t, agent.Tools, 1) + + tool, ok := agent.Tools[0].(map[string]any) + require.True(t, ok, "tool entry should decode to a map, got %T", agent.Tools[0]) + require.Equal(t, "some_tool_invented_next_year", tool["type"]) +} + +// TestPromptHarness_EmptyBlockIsNotAnError pins the documented equivalence +// between an empty `harness:` block and the old bare-name string: Type is the +// only required field, and decodeStrict must not turn a null node into an error. +func TestPromptHarness_EmptyBlockIsNotAnError(t *testing.T) { + t.Parallel() + + var harness PromptHarness + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("{}"), &node)) + require.NoError(t, harness.UnmarshalYAML(node.Content[0])) + require.Empty(t, harness.Type) +} + +// TestPromptHarness_RejectsListForm covers the remaining node kind. The message +// has to name what was found, since a list here usually means the author +// indented a `type:` under a `-`. +func TestPromptHarness_RejectsListForm(t *testing.T) { + t.Parallel() + + var agent PromptAgent + err := yaml.Unmarshal([]byte("kind: prompt\nname: a\nmodel: m\nharness:\n - type: x\n"), &agent) + require.Error(t, err) + require.True(t, + strings.Contains(err.Error(), "harness must be a block"), + "unexpected error: %v", err) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index bda2aa1a61d..a67e256ad15 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -4,7 +4,10 @@ package agent_yaml import ( + "bytes" + "errors" "fmt" + "io" "slices" "go.yaml.in/yaml/v3" @@ -447,6 +450,105 @@ type PromptHarnessBuiltInTools struct { Excluded *[]string `json:"excluded,omitempty" yaml:"excluded,omitempty"` } +// harnessTypeGitHubCopilotPreview duplicates +// agent_api.ManagedAgentHarnessGitHubCopilot. It is repeated here rather than +// imported because agent_api already depends on this package. +const harnessTypeGitHubCopilotPreview = "github_copilot_preview" + +// harnessTypeObsoleteAbbreviation is the pre-release spelling of +// harnessTypeGitHubCopilotPreview. It is rejected by name so an author who +// copied an older sample is told what to write instead, rather than having the +// value forwarded to a service that reports it as an opaque bad request. +const harnessTypeObsoleteAbbreviation = "ghcp" + +// UnmarshalYAML decodes the `harness:` block. +// +// Two things happen here that a plain struct decode would not do: +// +// - A scalar is rejected with the block that replaces it. `harness:` used to +// be a bare string, so an author carrying a manifest forward would otherwise +// get go-yaml's "cannot unmarshal !!str into agent_yaml.PromptHarness", +// which names a Go type and no fix. +// - Unknown keys are rejected. Every field of this block changes what the +// sandbox can do, so a typo that silently binds nothing — `builtin_tool:` +// for `builtin_tools:` — would deploy an agent with capabilities the author +// believed they had turned off. Tools stay `[]any` and are unaffected, so a +// tool type newer than this build still passes through. +func (h *PromptHarness) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode { + replacement := value.Value + if replacement == harnessTypeObsoleteAbbreviation { + replacement = harnessTypeGitHubCopilotPreview + } + return fmt.Errorf( + "harness must be a block, not a string: replace `harness: %s` with\n"+ + " harness:\n"+ + " type: %s", + value.Value, replacement) + } + + if value.Kind != yaml.MappingNode { + return fmt.Errorf("harness must be a block with a `type:` key, got %s", nodeKindName(value.Kind)) + } + + // A distinct type so this method is not inherited, which would recurse. + type harnessFields PromptHarness + var decoded harnessFields + if err := decodeStrict(value, &decoded); err != nil { + return fmt.Errorf("harness: %w", err) + } + + if decoded.Type == harnessTypeObsoleteAbbreviation { + return fmt.Errorf( + "harness.type %q is no longer accepted: use %q", + harnessTypeObsoleteAbbreviation, harnessTypeGitHubCopilotPreview) + } + + *h = PromptHarness(decoded) + return nil +} + +// decodeStrict decodes node into out, rejecting keys that bind to no field. +// +// yaml.Node.Decode has no strict mode, so the node is re-serialized and run +// through a Decoder that does. Nested blocks are covered by the same pass; +// fields typed `any` are not, which is what keeps pass-through fields such as +// PromptAgent.Tools forward-compatible. +func decodeStrict(node *yaml.Node, out any) error { + raw, err := yaml.Marshal(node) + if err != nil { + return fmt.Errorf("failed to re-encode: %w", err) + } + + decoder := yaml.NewDecoder(bytes.NewReader(raw)) + decoder.KnownFields(true) + if err := decoder.Decode(out); err != nil { + if errors.Is(err, io.EOF) { + // An empty block leaves the zero value in place. + return nil + } + return err + } + return nil +} + +// nodeKindName renders a yaml.Node kind for an error message, so a reader sees +// "a list" rather than the bit value go-yaml uses internally. +func nodeKindName(kind yaml.Kind) string { + switch kind { + case yaml.SequenceNode: + return "a list" + case yaml.ScalarNode: + return "a value" + case yaml.AliasNode: + return "an alias" + case yaml.DocumentNode: + return "a document" + default: + return "an unsupported node" + } +} + // PromptAgent represents a Foundry "prompt" agent — a PES (Prompt Execution // Service) backed agent. The customer declares the model and instructions; the // platform manages the runtime, lifecycle, and orchestration. @@ -598,6 +700,29 @@ type PromptMemory struct { Options *PromptMemoryOptions `json:"options,omitempty" yaml:"options,omitempty"` } +// UnmarshalYAML decodes the `memory:` block, rejecting keys that bind to no +// field. +// +// Memory is the one block azd acts on rather than forwards — it provisions the +// store and synthesizes the memory_search_preview tool — so a key that silently +// binds nothing produces an agent whose recall behavior differs from what the +// manifest says, with nothing in the deploy output to indicate it. +func (m *PromptMemory) UnmarshalYAML(value *yaml.Node) error { + if value.Kind != yaml.MappingNode { + return fmt.Errorf("memory must be a block with a `store:` key, got %s", nodeKindName(value.Kind)) + } + + // A distinct type so this method is not inherited, which would recurse. + type memoryFields PromptMemory + var decoded memoryFields + if err := decodeStrict(value, &decoded); err != nil { + return fmt.Errorf("memory: %w", err) + } + + *m = PromptMemory(decoded) + return nil +} + // PromptMemoryOptions toggles the extraction behaviors of a memory store. All // fields are pointers so an unset toggle leaves the service default rather than // forcing false. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go index bbaf9fdf6d2..fe4694c69da 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go @@ -806,11 +806,12 @@ func (p *AgentServiceTargetProvider) resolvePromptWorkspaceFromAzure( settings *PromptAgentSettings, env map[string]string, ) (string, bool) { - defer func() { - if r := recover(); r != nil { - fmt.Fprintf(os.Stderr, "Warning: workspace discovery panicked: %v\n", r) - } - }() + // No panic recovery here on purpose. A panic in discovery used to be + // converted into ("", false), which the caller cannot tell apart from "no + // workspace exists" -- so a crash silently became a request to provision a + // new workspace. It masked a nil credential reaching armresources.NewClient + // for the entire life of this function. deployPromptAgent already recovers + // at the RPC boundary and reports the panic as a deploy error. if settings == nil { return "", false From ffe8a60ebc61b442ec5e402328373112e569a9dd Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Thu, 27 Aug 2026 10:31:21 +0530 Subject: [PATCH 23/24] chore(ai-agents): keep new spelling terms in the extension cspell config The two words added to cli/azd/.vscode/cspell.yaml were not needed. subagents appears in no Go file, and builtin already occurs in core files that pass CI today, so cspell resolves it from its own dictionaries. Reverting keeps this branch out of the shared core config; the extension config covers the terms this work actually introduces. --- cli/azd/.vscode/cspell.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 436b8b430ca..0d943815dd3 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -1,8 +1,6 @@ import: ../../../.vscode/cspell.global.yaml words: - braydonk - - builtin - - subagents - osutil - upserted - upserting From 1789ef06f40ca5d88e105d946fc5eda79c3f14a1 Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Thu, 27 Aug 2026 16:32:56 +0530 Subject: [PATCH 24/24] feat(ai-agents): author prompt agents inline in azure.yaml `azd ai agent init` no longer writes a separate agent.yaml for prompt and managed agents. The definition -- kind, model, instructions, harness, tools, memory, connections, policies -- is written inline on the azure.yaml service entry, which is the shape hosted and voice agents already used, so every agent kind is authored in one file and `kind: prompt` is what identifies it. The config.promptAgent block is no longer written either. Every value it carried (subscription, resource group, workspace, project endpoint) is recorded in the azd environment by provision and read from there at deploy time, so the block could only ever hold a copy of the environment or a set of ${VAR} references pointing back at it. Hosted agents never had an equivalent. Deploy still accepts a definition in its own file through $ref:, the agent.yaml/agent.yml convention, or AGENT_DEFINITION_PATH, and still prefers a promptAgent block when one is present, so existing projects keep working. Also in this change: - Resolve prompt agents by `kind: prompt` rather than by the removed config block. list, show, invoke, delete and the down hooks all used the block as their discriminator and stopped recognizing an inline definition. - Surface the whole harness block in `azd ai agent show`, not just its type: pinned skills, sandbox CPU/memory/idle timeout, and allowed/excluded built-in tools. An explicit empty allow list renders as "(none)" so "nothing allowed" is distinguishable from "not configured". - Spell a RAI policy `rai_policy_name` in azure.yaml, matching the service and agent.yaml. Inline entries went through the JSON tag, which was camelCase, so the same field had two spellings depending on where it was written. The legacy key is still read. - Validate an inline harness/memory block as strictly as an authored one. UnmarshalYAML never runs on the inline path, so the typo checks added for agent.yaml would have been lost on what is now the default route. - Set servicePath for prompt agents, which an early return had skipped, so the skills/ and vector-assets/ convention folders resolve relative to the service directory when the definition is inline. --- .../internal/cmd/init_managed.go | 96 +---- .../azure.ai.agents/internal/cmd/list.go | 8 +- .../azure.ai.agents/internal/cmd/listen.go | 35 +- .../internal/cmd/prompt_service.go | 135 ++++--- .../internal/cmd/prompt_service_test.go | 113 ++++++ .../internal/cmd/resource_services.go | 23 +- .../azure.ai.agents/internal/cmd/show.go | 115 +++++- .../azure.ai.agents/internal/cmd/show_test.go | 82 ++++ .../internal/pkg/agents/agent_yaml/parse.go | 3 +- .../agents/agent_yaml/prompt_strict_inline.go | 143 +++++++ .../internal/pkg/agents/agent_yaml/yaml.go | 46 ++- .../internal/project/agent_policies_test.go | 54 ++- .../internal/project/prompt_graph.go | 6 +- .../internal/project/prompt_inline.go | 129 +++++++ .../internal/project/prompt_inline_test.go | 358 ++++++++++++++++++ .../internal/project/service_target_agent.go | 27 +- .../internal/project/service_target_prompt.go | 97 ++++- .../internal/synthesis/synthesizer.go | 10 +- 18 files changed, 1227 insertions(+), 253 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_strict_inline.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_inline.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/prompt_inline_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go index ff3d8e85393..68c318666a7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go @@ -4,10 +4,8 @@ package cmd import ( - "bytes" "context" "fmt" - "log" "net/http" "os" "path/filepath" @@ -21,20 +19,8 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/fatih/color" "go.yaml.in/yaml/v3" - "google.golang.org/protobuf/types/known/structpb" ) -// promptAgentManifestFileName is the agent definition filename `init` -// scaffolds. It is also referenced from azure.yaml through the service's `$ref` -// include, so the link between the service and its definition is visible in the -// project file rather than implied by a filename azd happens to look for. -const promptAgentManifestFileName = "agent.yaml" - -// promptAgentManifestRef is the `$ref` value written into azure.yaml. `$ref` -// paths resolve against the directory holding azure.yaml, and the explicit -// leading "./" marks it as a relative path rather than a bare name. -const promptAgentManifestRef = "./" + promptAgentManifestFileName - // promptAgentManifest is a prompt-agent definition supplied through // `--manifest` (or a positional template pointer), pre-loaded so runInitManaged // can seed the scaffold from it instead of prompting for each field. @@ -360,9 +346,6 @@ func runInitManaged( if err := applyRaiPolicySelection(ctx, azdClient, env.Name, &promptAgent, raiPolicy); err != nil { return err } - if err := writePromptAgentYAML(serviceRelPath, &promptAgent); err != nil { - return err - } // Scaffold the convention-based authoring layout (empty skills/ and // vector-assets/ folders) so the deploy engine's folder conventions are @@ -371,7 +354,7 @@ func runInitManaged( return err } - if err := addPromptAgentService(ctx, azdClient, agentName, serviceRelPath); err != nil { + if err := addPromptAgentService(ctx, azdClient, agentName, serviceRelPath, &promptAgent); err != nil { return err } @@ -414,25 +397,21 @@ func runInitManaged( return nil } -// addPromptAgentService registers the prompt agent as an azure.yaml service -// entry with Host=azure.ai.agent and a promptAgent config block. Unlike hosted -// agents there is no Docker/Language — the harness owns the runtime. -// -// Model deployments are deliberately NOT recorded here: they belong to the -// sibling azure.ai.project service that emitPromptResourceServices writes, the -// same shape hosted agents use. // addPromptAgentService registers the prompt agent as an azure.yaml service // entry with Host=azure.ai.agent. Unlike hosted agents there is no -// Docker/Language -- the harness owns the runtime. +// Docker/Language — the harness owns the runtime. +// +// The agent definition is written inline as service-level properties, the same +// unified shape hosted and voice agents use, so the whole agent is authored in +// azure.yaml and `kind: prompt` on the entry is what identifies it. Deploy also +// accepts a definition behind a `$ref:` include; init does not scaffold one +// because a second file adds nothing when there is only one agent to describe. // -// The config: block carries a promptAgent entry whose every field is a ${VAR} -// reference (see promptAgentEnvRefs). Its presence is the structural marker -// that distinguishes a prompt agent from a hosted one -- `azd ai agent init` -// writes no explicit kind: into the service config, and both the deploy provider -// and the provisioning synthesizer key off this block. Writing references rather -// than literals keeps the shape of the configuration visible while leaving the -// tenant-specific values in the azd environment, so the project can be copied to -// another subscription and deployed unchanged. +// No promptAgent config block is written. Every value it used to carry — +// subscription, resource group, workspace, project endpoint — is recorded in +// the azd environment by `azd provision` and read from there at deploy time, so +// the block could only have held a copy of the environment or a set of ${VAR} +// references pointing back at it. // // Model deployments are deliberately NOT recorded here: they belong to the // sibling azure.ai.project service that emitResourceServices writes, the @@ -441,25 +420,11 @@ func addPromptAgentService( ctx context.Context, azdClient *azdext.AzdClient, agentName, serviceRelPath string, + promptAgent *agent_yaml.PromptAgent, ) error { - agentConfig := project.ServiceTargetAgentConfig{ - PromptAgent: promptAgentEnvRefs(), - } - configStruct, err := project.MarshalStruct(&agentConfig) - if err != nil { - return fmt.Errorf("marshaling prompt agent service config: %w", err) - } - - // Reference the definition file explicitly on the service entry. Deploy would - // find agent.yaml by convention anyway, but the `$ref` include makes the - // service -> definition edge readable in azure.yaml, gives the developer one - // line to edit when they want a different filename, and is the same directive - // every other Foundry resource uses to live in its own file. - serviceProps, err := structpb.NewStruct(map[string]any{ - project.AgentDefinitionRefKey: promptAgentManifestRef, - }) + agentProps, err := project.PromptAgentDefinitionToServiceProperties(*promptAgent) if err != nil { - return fmt.Errorf("marshaling prompt agent service properties: %w", err) + return err } req := &azdext.AddServiceRequest{ @@ -467,8 +432,7 @@ func addPromptAgentService( Name: agentName, RelativePath: serviceRelPath, Host: AiAgentHost, - Config: configStruct, - AdditionalProperties: serviceProps, + AdditionalProperties: agentProps, }, } if _, err := azdClient.Project().AddService(ctx, req); err != nil { @@ -747,32 +711,8 @@ func promptManagedAgentInstructions( return instructions, nil } -// writePromptAgentYAML serializes the PromptAgent and writes it to -// /agent.yaml. A schema annotation comment is prepended for editor -// validation parity with the hosted agent flow. -func writePromptAgentYAML(targetDir string, promptAgent *agent_yaml.PromptAgent) error { - content, err := yaml.Marshal(promptAgent) - if err != nil { - return fmt.Errorf("marshaling prompt agent to YAML: %w", err) - } - - annotation := "# yaml-language-server: " + - "$schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/PromptAgent.yaml" - buf := bytes.NewBufferString(annotation + "\n\n") - if _, err := buf.Write(content); err != nil { - return fmt.Errorf("preparing agent.yaml file contents: %w", err) - } - - filePath := filepath.Join(targetDir, promptAgentManifestFileName) - if err := os.WriteFile(filePath, buf.Bytes(), osutil.PermissionFile); err != nil { - return fmt.Errorf("saving file to %s: %w", filePath, err) - } - log.Printf("Wrote prompt agent.yaml at %s", filePath) - return nil -} - // promptScaffoldInstructions returns the instructions to write inline into a -// scaffolded agent.yaml, falling back to a neutral default so a freshly +// scaffolded agent definition, falling back to a neutral default so a freshly // initialized agent is deployable without editing. func promptScaffoldInstructions(instructions string) string { if trimmed := strings.TrimSpace(instructions); trimmed != "" { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/list.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/list.go index 95168076690..47be8b4829f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/list.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/list.go @@ -34,8 +34,9 @@ func newListCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Short: "List prompt agents on the harness.", Long: `List the prompt agents registered on the managed harness. -The target harness is read from the azure.ai.agent service config in azure.yaml -(written by 'azd ai agent init'). This command targets prompt agents only.`, +The target harness is derived from the azd environment (subscription, resource +group, and Foundry project). This command targets prompt agents only, meaning an +azure.ai.agent service declaring 'kind: prompt' in azure.yaml.`, Example: ` # List prompt agents on the configured harness azd ai agent list @@ -80,7 +81,8 @@ func (a *ListAction) Run(ctx context.Context) error { } if !isPrompt { return fmt.Errorf( - "the azure.ai.agent service is not a prompt agent; `azd ai agent list` targets prompt agents only", + "the azure.ai.agent service is not a prompt agent; " + + "`azd ai agent list` targets services declaring `kind: prompt` in azure.yaml", ) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 67bf6245a37..0c2c8a24340 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -92,13 +92,12 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args if isHostedAgentService(svc, args.Project) { hostedAgentCount++ } - // Prompt (kind=managed) agents have no container to provision + // Prompt (kind=prompt) agents have no container to provision // settings for — the harness owns the runtime. But they DO carry a // model deployment in their service config, so still run envUpdate // (which translates `deployments` into AI_PROJECT_DEPLOYMENTS for // Bicep). Only the container-settings step is hosted-specific. - _, isPrompt := promptSettingsFromService(svc) - if !isPrompt { + if !project.ServiceIsPromptAgent(svc) { if err := prepareContainerSettings(svc, args.Project.Path); err != nil { return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) } @@ -287,11 +286,11 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az return err } - // Prompt (kind=managed) agents have no container settings — the harness owns + // Prompt (kind=prompt) agents have no container settings — the harness owns // the runtime. Without this guard SetAgentContainerSettings writes default // memory/cpu onto the service and persists them into azure.yaml for an agent // azd does not host. - if _, isPrompt := promptSettingsFromService(svc); !isPrompt { + if !project.ServiceIsPromptAgent(svc) { if err := prepareContainerSettings(svc, args.Project.Path); err != nil { return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) } @@ -626,8 +625,14 @@ func predownHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azde if svc.Host != AiAgentHost { continue } - settings, isPrompt := promptSettingsFromService(svc) - if !isPrompt { + if !project.ServiceIsPromptAgent(svc) { + continue + } + // Resolved the same way deploy does: the harness target comes from the + // azd environment, with the optional promptAgent block layered on top. + settings, err := project.ResolvePromptAgentSettings(promptSettingsFromService(svc), envValues) + if err != nil { + log.Printf("predown: skipping harness delete for %q: %v", svc.Name, err) continue } deletePromptAgentOnDown(ctx, svc, settings, args.Project.Path, envValues) @@ -646,24 +651,20 @@ func deletePromptAgentOnDown( projectPath string, envValues map[string]string, ) { - settings.ApplyEnvOverrides() - if err := settings.Validate(); err != nil { - log.Printf("predown: skipping harness delete for %q: %v", svc.Name, err) - return - } // Apply the same azd environment-derived target resolution deploy and the // other lifecycle commands use. Without it a non-guided project keeps the - // placeholder workspace tuple from azure.yaml and the delete is routed at a - // workspace that never existed. + // placeholder workspace tuple and the delete is routed at a workspace that + // never existed. if envValues != nil { if _, mapErr := project.ResolvePromptTargetFromEnv(settings, envValues); mapErr != nil { log.Printf("predown: skipping harness delete for %q: %v", svc.Name, mapErr) return } } - // Delete by the agent.yaml name — the identity every other prompt lifecycle - // path uses. The azure.yaml service key only matches when agent.yaml omits - // `name:`, which is true for scaffolded projects but not for renamed agents. + // Delete by the definition's `name` — the identity every other prompt + // lifecycle path uses. The azure.yaml service key only matches when the + // definition omits `name:`, which is true for scaffolded projects but not for + // renamed agents. agentName := promptAgentNameForService(svc, projectPath) client, err := project.NewPromptAgentClient(settings) if err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go index 0008910535a..6a3c48a42ab 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go @@ -29,26 +29,61 @@ type promptServiceContext struct { Agent agent_yaml.PromptAgent } -// promptSettingsFromService extracts the prompt-agent harness settings from a -// service config. The bool is false when the service is not a prompt agent -// (no promptAgent block), letting callers fall back to the hosted path. -func promptSettingsFromService(svc *azdext.ServiceConfig) (*project.PromptAgentSettings, bool) { +// promptSettingsFromService extracts the optional `promptAgent` override block +// from a service config. +// +// A nil result is the normal case: `azd ai agent init` no longer writes the +// block, because everything it carried is read from the azd environment. It is +// returned as-is (not resolved) so the caller can layer it the same way deploy +// does. It is NOT a prompt-agent discriminator — use +// [project.ServiceIsPromptAgent] or the resolved definition for that. +func promptSettingsFromService(svc *azdext.ServiceConfig) *project.PromptAgentSettings { if svc == nil || svc.Config == nil { - return nil, false + return nil } var cfg project.ServiceTargetAgentConfig if err := project.UnmarshalStruct(svc.Config, &cfg); err != nil { - return nil, false + return nil } - if cfg.PromptAgent == nil { - return nil, false + return cfg.PromptAgent +} + +// promptDefinitionForService returns the prompt-agent definition backing a +// service, and whether the service is a prompt agent at all. +// +// The definition is normally inline on the azure.yaml service entry, which is +// also where `kind: prompt` identifies it; a `$ref:` include is expanded by the +// same call. Projects that predate the inline shape declare no kind and keep +// their definition in an on-disk agent.yaml, so those are recognized by their +// `promptAgent` config block and read from the file. +func promptDefinitionForService( + svc *azdext.ServiceConfig, + projectPath, serviceDir string, +) (agent_yaml.PromptAgent, bool) { + if def, found, err := project.PromptAgentFromResolvedService(svc, projectPath); err == nil && found { + return def, true + } + + if !project.ServiceIsPromptAgent(svc) { + return agent_yaml.PromptAgent{}, false + } + + // Legacy shape. Best-effort: an unreadable file still leaves a usable + // context, since the service key doubles as the agent identity. + if serviceDir != "" { + if data, err := os.ReadFile(filepath.Join(serviceDir, "agent.yaml")); err == nil { + var def agent_yaml.PromptAgent + if yaml.Unmarshal(data, &def) == nil { + return def, true + } + } } - return cfg.PromptAgent, true + return agent_yaml.PromptAgent{}, true } // resolvePromptAgentService resolves the named (or sole) azure.ai.agent service // and, when it is a prompt (kind=prompt) agent, returns its harness settings -// and parsed agent.yaml. The bool is false when the resolved service is NOT a +// and parsed definition. The bool is false when the resolved service is NOT a // prompt agent, so callers can fall back to the hosted code path. func resolvePromptAgentService( ctx context.Context, @@ -61,32 +96,38 @@ func resolvePromptAgentService( return nil, false, err } - settings, ok := promptSettingsFromService(svc) - if !ok { + projectPath := "" + serviceDir := "" + if proj != nil { + projectPath = proj.Path + if dir, joinErr := paths.JoinAllowRoot(proj.Path, svc.RelativePath); joinErr == nil { + serviceDir = dir + } + } + + agentDef, isPrompt := promptDefinitionForService(svc, projectPath, serviceDir) + if !isPrompt { return nil, false, nil } - // Resolve the block exactly as deploy does. azure.yaml carries ${VAR} - // references so the project stays portable, so the raw config holds literal - // "${AZURE_AI_PROJECT_ENDPOINT}" strings; without expansion these commands - // fail with "is not a valid absolute URL" instead of reaching the harness. - // The azd environment is best-effort — when it cannot be read, expansion - // falls back to the process environment and unset references collapse to the + // Resolve the harness target exactly as deploy does: the subscription, + // resource group, workspace, and project endpoint come from the azd + // environment, and the optional promptAgent block is layered on top. The + // environment read is best-effort — when it cannot be read, expansion falls + // back to the process environment and unset references collapse to the // defaults, which is what lets these commands run in a project that has not // been provisioned yet. envValues, envErr := promptEnvValues(ctx, azdClient) - resolved, err := project.ResolvePromptAgentSettings(settings, envValues) + settings, err := project.ResolvePromptAgentSettings(promptSettingsFromService(svc), envValues) if err != nil { return nil, false, err } - settings = resolved // Apply the same azd environment-derived target resolution that deploy uses // so lifecycle commands (show/invoke/list/delete) hit the identical managed // workspace route (@@AML) the agent was created on. Without - // this, these commands resolve promptAgent.workspace from azure.yaml verbatim - // and query a non-existent workspace, yielding an HTML 404 the client cannot - // parse. + // this, these commands resolve the workspace verbatim and query a + // non-existent one, yielding an HTML 404 the client cannot parse. if envErr == nil { if _, mapErr := project.ResolvePromptTargetFromEnv(settings, envValues); mapErr != nil { return nil, false, mapErr @@ -95,53 +136,35 @@ func resolvePromptAgentService( pctx := &promptServiceContext{ ServiceName: svc.Name, + ServiceDir: serviceDir, Settings: settings, + Agent: agentDef, } - - if proj != nil { - if dir, joinErr := paths.JoinAllowRoot(proj.Path, svc.RelativePath); joinErr == nil { - pctx.ServiceDir = dir - } - } - - // Parse the agent.yaml that backs the service to recover the model and - // (default) agent name. Best-effort: the service Name is used as the agent - // identity when agent.yaml cannot be read. - pctx.Agent.Name = svc.Name - if pctx.ServiceDir != "" { - if data, readErr := os.ReadFile(filepath.Join(pctx.ServiceDir, "agent.yaml")); readErr == nil { - var promptDef agent_yaml.PromptAgent - if yaml.Unmarshal(data, &promptDef) == nil && promptDef.Name != "" { - pctx.Agent = promptDef - } - } + if strings.TrimSpace(pctx.Agent.Name) == "" { + pctx.Agent.Name = svc.Name } return pctx, true, nil } // promptAgentNameForService returns the harness agent identity for a prompt -// service: the `name` declared in its agent.yaml, falling back to the -// azure.yaml service key when agent.yaml is absent or declares no name. It is -// the lightweight counterpart of promptServiceContext.AgentName for callers -// (like the down handlers) that only have a ServiceConfig. +// service: the `name` its definition declares, falling back to the azure.yaml +// service key. It is the lightweight counterpart of +// promptServiceContext.AgentName for callers (like the down handlers) that only +// have a ServiceConfig. func promptAgentNameForService(svc *azdext.ServiceConfig, projectPath string) string { if svc == nil { return "" } - dir, err := paths.JoinAllowRoot(projectPath, svc.RelativePath) - if err != nil { - return svc.Name - } - data, err := os.ReadFile(filepath.Join(dir, "agent.yaml")) - if err != nil { - return svc.Name + serviceDir := "" + if dir, err := paths.JoinAllowRoot(projectPath, svc.RelativePath); err == nil { + serviceDir = dir } - var def agent_yaml.PromptAgent - if err := yaml.Unmarshal(data, &def); err != nil || strings.TrimSpace(def.Name) == "" { - return svc.Name + def, _ := promptDefinitionForService(svc, projectPath, serviceDir) + if name := strings.TrimSpace(def.Name); name != "" { + return name } - return def.Name + return svc.Name } // AgentName returns the harness agent identity for the resolved service. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service_test.go new file mode 100644 index 00000000000..007ce008d79 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service_test.go @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPromptDefinitionForServiceInline covers the shape `azd ai agent init` +// writes today: the definition lives on the azure.yaml service entry and +// `kind: prompt` is the only marker. Reading it back is what makes +// list/show/invoke/delete recognize the service at all. +func TestPromptDefinitionForServiceInline(t *testing.T) { + svc := &azdext.ServiceConfig{ + Name: "my-agent", + Host: AiAgentHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "kind": "prompt", + "name": "renamed-agent", + "model": "gpt-5.6-terra", + "instructions": "You are a helpful AI assistant.", + "harness": map[string]any{ + "type": "github_copilot_preview", + }, + }), + } + + def, isPrompt := promptDefinitionForService(svc, t.TempDir(), "") + require.True(t, isPrompt) + assert.Equal(t, "renamed-agent", def.Name) + assert.Equal(t, "gpt-5.6-terra", def.Model) + require.NotNil(t, def.Harness) + assert.Equal(t, "github_copilot_preview", def.Harness.Type) +} + +// TestPromptDefinitionForServiceHosted guards the dispatch: a hosted agent must +// fall through to the hosted code path rather than being handed to the harness. +func TestPromptDefinitionForServiceHosted(t *testing.T) { + svc := &azdext.ServiceConfig{ + Name: "hosted-agent", + Host: AiAgentHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "kind": "hosted", + "name": "hosted-agent", + }), + } + + _, isPrompt := promptDefinitionForService(svc, t.TempDir(), "") + assert.False(t, isPrompt) +} + +// TestPromptDefinitionForServiceLegacy covers projects scaffolded before the +// definition moved inline: they declare no kind, are identified by their +// promptAgent config block, and keep the definition in an on-disk agent.yaml. +func TestPromptDefinitionForServiceLegacy(t *testing.T) { + serviceDir := t.TempDir() + agentYaml := "kind: prompt\nname: legacy-agent\nmodel: gpt-4o\nharness:\n type: github_copilot_preview\n" + require.NoError(t, os.WriteFile(filepath.Join(serviceDir, "agent.yaml"), []byte(agentYaml), 0600)) + + svc := &azdext.ServiceConfig{ + Name: "legacy", + Host: AiAgentHost, + Config: mustStruct(t, map[string]any{ + "promptAgent": map[string]any{"baseUrl": "https://example.invalid"}, + }), + } + + def, isPrompt := promptDefinitionForService(svc, filepath.Dir(serviceDir), serviceDir) + require.True(t, isPrompt) + assert.Equal(t, "legacy-agent", def.Name) + require.NotNil(t, def.Harness) + assert.Equal(t, "github_copilot_preview", def.Harness.Type) +} + +// TestPromptAgentNameForServicePrefersDefinition asserts the down handlers +// delete the agent the definition names, not the azure.yaml service key. The +// two diverge as soon as an agent is renamed. +func TestPromptAgentNameForServicePrefersDefinition(t *testing.T) { + svc := &azdext.ServiceConfig{ + Name: "service-key", + Host: AiAgentHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "kind": "prompt", + "name": "renamed-agent", + }), + } + + assert.Equal(t, "renamed-agent", promptAgentNameForService(svc, t.TempDir())) +} + +// TestPromptSettingsFromServiceOptional asserts a missing promptAgent block is +// not an error. init stopped writing the block, so nil is the common case and +// the settings are resolved from the azd environment instead. +func TestPromptSettingsFromServiceOptional(t *testing.T) { + assert.Nil(t, promptSettingsFromService(nil)) + assert.Nil(t, promptSettingsFromService(&azdext.ServiceConfig{Name: "svc"})) + + settings := promptSettingsFromService(&azdext.ServiceConfig{ + Name: "svc", + Config: mustStruct(t, map[string]any{ + "promptAgent": map[string]any{"apiVersion": "2025-11-15-preview"}, + }), + }) + require.NotNil(t, settings) + assert.Equal(t, "2025-11-15-preview", settings.APIVersion) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go index cbd9c4cf988..01d84189a6b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -60,30 +60,11 @@ const ( // projectWorkspaceEnvVar carries the AML workspace name backing the Foundry // project (@@AML). The managed control plane's agent - // routes are workspace-scoped, so the promptAgent block references it - // instead of embedding the tenant-specific name in azure.yaml. + // routes are workspace-scoped, so the deploy path reads it from the azd + // environment rather than from azure.yaml. projectWorkspaceEnvVar = "AZURE_AI_WORKSPACE" ) -// promptAgentEnvRefs returns the promptAgent block `azd ai agent init` writes -// into azure.yaml. Every field is a ${VAR} reference rather than a literal, so -// the file carries no subscription, resource group, or workspace of its own and -// can be copied between Foundry projects unchanged: `azd up` in a new -// environment resolves each field from that environment. -// -// The deploy path expands these references against the azd environment and -// falls back to the built-in defaults for any variable that is unset, so a -// project cloned without an environment still initializes. -func promptAgentEnvRefs() *project.PromptAgentSettings { - return &project.PromptAgentSettings{ - BaseURL: "${" + project.PromptBaseURLEnvVar + "}", - SubscriptionID: "${AZURE_SUBSCRIPTION_ID}", - ResourceGroup: "${AZURE_RESOURCE_GROUP}", - Workspace: "${" + projectWorkspaceEnvVar + "}", - ProjectEndpoint: projectEndpointRef, - } -} - // promptResourceServices derives the sibling Foundry services a prompt or // managed agent needs from its scaffolded definition and folder layout, so a // prompt agent's azure.yaml carries the same hosts as a hosted agent's. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go index 064ac57a750..15c3722a43d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go @@ -17,6 +17,7 @@ import ( "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" projectpkg "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -210,9 +211,9 @@ func (a *ShowAction) Run(ctx context.Context) error { return printShowResult(result, a.flags.output, suggestions) } -// runPromptShow handles `azd ai agent show` for a prompt (kind=managed) agent. -// It is dispatched from RunE when the resolved azure.ai.agent service carries a -// promptAgent config block. The status comes from the harness GetAgent API +// runPromptShow handles `azd ai agent show` for a prompt (kind=prompt) agent. +// It is dispatched from RunE when the resolved azure.ai.agent service resolves +// to a prompt-agent definition. The status comes from the harness GetAgent API // rather than the Foundry agent endpoint. func runPromptShow(ctx context.Context, flags *showFlags, pctx *promptServiceContext) error { agentName := pctx.AgentName() @@ -234,13 +235,13 @@ func runPromptShow(ctx context.Context, flags *showFlags, pctx *promptServiceCon } fmt.Println(string(data)) default: - printPromptShowTable(agent, pctx.Settings) + printPromptShowTable(agent, pctx) } return nil } // printPromptShowTable renders a concise status table for a prompt agent. -func printPromptShowTable(agent *agent_api.AgentObject, settings *projectpkg.PromptAgentSettings) { +func printPromptShowTable(agent *agent_api.AgentObject, pctx *promptServiceContext) { latest := agent.Versions.Latest w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintf(w, "Name:\t%s\n", agent.Name) @@ -258,13 +259,11 @@ func printPromptShowTable(agent *agent_api.AgentObject, settings *projectpkg.Pro // from the deployed definition's `harness` block. The previous // implementation printed settings.BaseURL here, which is the harness *API // base URL*, not the harness itself. - if harness := harnessTypeFromMap(def); harness != "" { - fmt.Fprintf(w, "Harness:\t%s\n", displayHarness(harness)) - } + printPromptHarness(w, promptHarnessFromMap(def), pctx.Agent.Harness) // Project endpoint is where the agent is actually served/invoked. This is // the useful "where does this live" value that Harness was standing in for. - if endpoint := promptAgentEndpoint(settings); endpoint != "" { + if endpoint := promptAgentEndpoint(pctx.Settings); endpoint != "" { fmt.Fprintf(w, "Project Endpoint:\t%s\n", endpoint) } @@ -297,25 +296,105 @@ func stringFromMap(m map[string]any, key string) string { return "" } -// harnessTypeFromMap returns the harness discriminator from a deployed -// definition. +// promptHarnessFromMap decodes the `harness` block of a deployed definition. // // Both shapes are handled because the field changed: agents created by earlier -// versions of azd carry a bare string, current ones carry an object with a -// `type`. Reading only one shape would blank the Harness row for half the -// agents in a project. -func harnessTypeFromMap(def map[string]any) string { +// versions of azd carry a bare harness name, current ones carry an object. +// Reading only one shape would blank the Harness row for half the agents in a +// project. +func promptHarnessFromMap(def map[string]any) *agent_yaml.PromptHarness { if def == nil { - return "" + return nil } switch harness := def["harness"].(type) { case string: - return strings.TrimSpace(harness) + return &agent_yaml.PromptHarness{Type: strings.TrimSpace(harness)} case map[string]any: - return stringFromMap(harness, "type") + data, err := json.Marshal(harness) + if err != nil { + return nil + } + var out agent_yaml.PromptHarness + if err := json.Unmarshal(data, &out); err != nil { + return nil + } + return &out default: + return nil + } +} + +// harnessTypeFromMap returns the harness discriminator from a deployed +// definition. +func harnessTypeFromMap(def map[string]any) string { + harness := promptHarnessFromMap(def) + if harness == nil { return "" } + return strings.TrimSpace(harness.Type) +} + +// printPromptHarness renders the execution harness the agent runs on: its type +// plus the sandbox configuration the harness owns (pinned skills, compute size, +// and which built-in capabilities the agent may reach). +// +// The deployed block wins because it describes what is actually running, but the +// locally authored one is used as a fallback so the rows stay populated for an +// agent whose deployed definition predates the `harness:` object. +func printPromptHarness(w io.Writer, deployed, local *agent_yaml.PromptHarness) { + harness := deployed + if harness == nil || strings.TrimSpace(harness.Type) == "" { + harness = local + } + if harness == nil || strings.TrimSpace(harness.Type) == "" { + return + } + + fmt.Fprintf(w, "Harness:\t%s\n", displayHarness(strings.TrimSpace(harness.Type))) + + if len(harness.Skills) > 0 { + names := make([]string, 0, len(harness.Skills)) + for _, skill := range harness.Skills { + if version := strings.TrimSpace(skill.Version); version != "" { + names = append(names, fmt.Sprintf("%s@%s", skill.Name, version)) + continue + } + names = append(names, skill.Name) + } + fmt.Fprintf(w, " Skills:\t%s\n", strings.Join(names, ", ")) + } + + if env := harness.Environment; env != nil { + if cpu := strings.TrimSpace(env.Cpu); cpu != "" { + fmt.Fprintf(w, " CPU:\t%s\n", cpu) + } + if memory := strings.TrimSpace(env.Memory); memory != "" { + fmt.Fprintf(w, " Memory:\t%s\n", memory) + } + if env.IdleTimeoutSeconds != nil { + fmt.Fprintf(w, " Idle Timeout:\t%ds\n", *env.IdleTimeoutSeconds) + } + } + + // An explicit empty list is meaningful (`allowed: []` turns every built-in + // capability off), so a non-nil pointer always prints, even when empty. + if tools := harness.BuiltinTools; tools != nil { + if tools.Allowed != nil { + fmt.Fprintf(w, " Built-in Tools Allowed:\t%s\n", displayToolList(*tools.Allowed)) + } + if tools.Excluded != nil { + fmt.Fprintf(w, " Built-in Tools Excluded:\t%s\n", displayToolList(*tools.Excluded)) + } + } +} + +// displayToolList renders a built-in capability list, naming the empty case so +// "none allowed" is not mistaken for "not configured". +func displayToolList(tools []string) string { + if len(tools) == 0 { + return "(none)" + } + return strings.Join(tools, ", ") } // displayHarness maps a harness identifier to a friendlier label, preserving diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go index 489dbf1754e..d905656fd17 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go @@ -4,6 +4,7 @@ package cmd import ( + "bytes" "encoding/json" "io" "os" @@ -12,6 +13,7 @@ import ( "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" projectpkg "azureaiagent/internal/project" "github.com/stretchr/testify/assert" @@ -428,6 +430,86 @@ func TestPromptDefinitionMap(t *testing.T) { assert.Equal(t, "", stringFromMap(nil, "harness")) } +// TestPromptHarnessFromMap asserts the whole harness block round-trips out of a +// deployed definition, not just its type: `show` surfaces the sandbox +// configuration the harness owns. +func TestPromptHarnessFromMap(t *testing.T) { + harness := promptHarnessFromMap(map[string]any{ + "harness": map[string]any{ + "type": "github_copilot_preview", + "skills": []any{map[string]any{"name": "code-review", "version": "3"}}, + "environment": map[string]any{"cpu": "1", "memory": "2Gi", "idle_timeout_seconds": float64(300)}, + "builtin_tools": map[string]any{ + "allowed": []any{"bash"}, + "excluded": []any{}, + }, + }, + }) + require.NotNil(t, harness) + assert.Equal(t, "github_copilot_preview", harness.Type) + require.Len(t, harness.Skills, 1) + assert.Equal(t, "code-review", harness.Skills[0].Name) + assert.Equal(t, "3", harness.Skills[0].Version) + require.NotNil(t, harness.Environment) + assert.Equal(t, "1", harness.Environment.Cpu) + assert.Equal(t, "2Gi", harness.Environment.Memory) + require.NotNil(t, harness.Environment.IdleTimeoutSeconds) + assert.Equal(t, 300, *harness.Environment.IdleTimeoutSeconds) + require.NotNil(t, harness.BuiltinTools) + require.NotNil(t, harness.BuiltinTools.Allowed) + assert.Equal(t, []string{"bash"}, *harness.BuiltinTools.Allowed) + + // Legacy bare-string shape still yields a type. + legacy := promptHarnessFromMap(map[string]any{"harness": "github_copilot_preview"}) + require.NotNil(t, legacy) + assert.Equal(t, "github_copilot_preview", legacy.Type) + + assert.Nil(t, promptHarnessFromMap(nil)) + assert.Nil(t, promptHarnessFromMap(map[string]any{})) +} + +func TestPrintPromptHarness(t *testing.T) { + idle := 300 + deployed := &agent_yaml.PromptHarness{ + Type: "github_copilot_preview", + Skills: []agent_yaml.HarnessSkillRef{{Name: "code-review", Version: "3"}, {Name: "docs"}}, + Environment: &agent_yaml.PromptHarnessEnvironment{ + Cpu: "1", + Memory: "2Gi", + IdleTimeoutSeconds: &idle, + }, + BuiltinTools: &agent_yaml.PromptHarnessBuiltInTools{ + Allowed: &[]string{"bash", "web_search"}, + Excluded: &[]string{}, + }, + } + + var buf bytes.Buffer + printPromptHarness(&buf, deployed, nil) + out := buf.String() + assert.Contains(t, out, "Harness:\tGitHub Copilot (github_copilot_preview)\n") + assert.Contains(t, out, " Skills:\tcode-review@3, docs\n") + assert.Contains(t, out, " CPU:\t1\n") + assert.Contains(t, out, " Memory:\t2Gi\n") + assert.Contains(t, out, " Idle Timeout:\t300s\n") + assert.Contains(t, out, " Built-in Tools Allowed:\tbash, web_search\n") + // An explicit empty list disables every built-in capability, which is not the + // same as leaving the field out, so it must still render. + assert.Contains(t, out, " Built-in Tools Excluded:\t(none)\n") +} + +// TestPrintPromptHarnessFallsBackToLocal covers an agent deployed before the +// harness block existed: the locally authored definition keeps the row honest. +func TestPrintPromptHarnessFallsBackToLocal(t *testing.T) { + var buf bytes.Buffer + printPromptHarness(&buf, nil, &agent_yaml.PromptHarness{Type: "github_copilot_preview"}) + assert.Contains(t, buf.String(), "Harness:\tGitHub Copilot (github_copilot_preview)\n") + + buf.Reset() + printPromptHarness(&buf, nil, nil) + assert.Empty(t, buf.String()) +} + func TestPrintPromptToolboxTools(t *testing.T) { def := map[string]any{ "tools": []any{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go index 570d342cbe4..4e522ffb038 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go @@ -427,8 +427,7 @@ func ValidateAgentDefinition(templateBytes []byte) error { raiPolicyCount++ if policy.RaiPolicyName == "" { errors = append(errors, fmt.Sprintf( - "policies[%d] of type '%s' requires a policy name "+ - "('raiPolicyName' in azure.yaml, 'rai_policy_name' in agent.yaml)", + "policies[%d] of type '%s' requires a policy name ('rai_policy_name')", i, policy.Type)) } else if err := ValidateRaiPolicyName(policy.RaiPolicyName); err != nil { errors = append(errors, fmt.Sprintf("policies[%d]: %v", i, err)) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_strict_inline.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_strict_inline.go new file mode 100644 index 00000000000..ebfe9bc8dc4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_strict_inline.go @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// A prompt agent's definition reaches azd by one of two routes, and only one of +// them runs the YAML decoder: +// +// - Inline on the azure.yaml service entry. Core azd parses azure.yaml, hands +// the service properties to the extension as protobuf, and the extension +// decodes them as JSON. The UnmarshalYAML methods in yaml.go never run. +// - From a file named by `$ref:` (or the legacy agent.yaml convention), which +// the deploy path reads and decodes as YAML. +// +// Inline is the shape `azd ai agent init` scaffolds, so without the checks below +// the common case would be the unchecked one: a `harness:` typo would silently +// bind nothing and deploy an agent with capabilities the author believed they +// had turned off. These functions apply the same rules to a decoded value that +// [PromptHarness.UnmarshalYAML] and [PromptMemory.UnmarshalYAML] apply to a +// yaml.Node, so both routes reject the same manifests with the same messages. + +// errHarnessStringForm reports the pre-block `harness: ` spelling, +// echoing the block that replaces it. An author carrying an older manifest +// forward is shown the replacement rather than a Go type name. +func errHarnessStringForm(value string) error { + replacement := value + if replacement == harnessTypeObsoleteAbbreviation { + replacement = harnessTypeGitHubCopilotPreview + } + return fmt.Errorf( + "harness must be a block, not a string: replace `harness: %s` with\n"+ + " harness:\n"+ + " type: %s", + value, replacement) +} + +// errHarnessObsoleteType reports the retired `ghcp` harness type by name so the +// value is not forwarded to a service that reports it as an opaque bad request. +func errHarnessObsoleteType() error { + return fmt.Errorf( + "harness.type %q is no longer accepted: use %q", + harnessTypeObsoleteAbbreviation, harnessTypeGitHubCopilotPreview) +} + +// ValidateInlinePromptAgent applies the authored-block rules to prompt-agent +// properties that were decoded outside this package, such as the inline +// definition carried on an azure.yaml service entry. +// +// props is the raw property bag. Keys the prompt agent forwards verbatim +// (tools, text, reasoning, structured_inputs) are deliberately not inspected so +// a tool type newer than this build still passes through. +func ValidateInlinePromptAgent(props map[string]any) error { + if raw, ok := props["harness"]; ok { + if err := validateInlineHarness(raw); err != nil { + return err + } + } + if raw, ok := props["memory"]; ok { + if err := validateInlineMemory(raw); err != nil { + return err + } + } + return nil +} + +// validateInlineHarness mirrors [PromptHarness.UnmarshalYAML]. +func validateInlineHarness(value any) error { + switch v := value.(type) { + case nil: + // An empty block leaves the zero value in place, matching decodeStrict. + return nil + case string: + return errHarnessStringForm(v) + case map[string]any: + if declared, ok := v["type"].(string); ok && declared == harnessTypeObsoleteAbbreviation { + return errHarnessObsoleteType() + } + // A distinct type so the YAML method is not inherited, matching the + // decoder path. + type harnessFields PromptHarness + var decoded harnessFields + if err := decodeStrictJSON(v, &decoded); err != nil { + return fmt.Errorf("harness: %w", err) + } + return nil + default: + return fmt.Errorf("harness must be a block with a `type:` key, got %s", inlineKindName(value)) + } +} + +// validateInlineMemory mirrors [PromptMemory.UnmarshalYAML]. +func validateInlineMemory(value any) error { + if value == nil { + return nil + } + fields, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("memory must be a block with a `store:` key, got %s", inlineKindName(value)) + } + // A distinct type so the YAML method is not inherited, matching the decoder + // path. + type memoryFields PromptMemory + var decoded memoryFields + if err := decodeStrictJSON(fields, &decoded); err != nil { + return fmt.Errorf("memory: %w", err) + } + return nil +} + +// decodeStrictJSON decodes value into out, rejecting keys that bind to no +// field. It is the JSON counterpart of decodeStrict. +func decodeStrictJSON(value any, out any) error { + raw, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("failed to re-encode: %w", err) + } + + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + return decoder.Decode(out) +} + +// inlineKindName renders a decoded value's shape for an error message, so a +// reader sees "a list" rather than a Go type name. It is the counterpart of +// nodeKindName. +func inlineKindName(value any) string { + switch value.(type) { + case []any: + return "a list" + case string, bool, float64, int, int64: + return "a value" + case nil: + return "an empty value" + default: + return "an unsupported value" + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index a67e256ad15..5a42d72f3f3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -5,6 +5,7 @@ package agent_yaml import ( "bytes" + "encoding/json" "errors" "fmt" "io" @@ -355,10 +356,39 @@ type InvocationsModeration struct { // InvocationsModeration is optional and only valid for agents exposing the invocations protocol. type Policy struct { Type PolicyType `json:"type" yaml:"type"` - RaiPolicyName string `json:"raiPolicyName,omitempty" yaml:"rai_policy_name,omitempty"` + RaiPolicyName string `json:"rai_policy_name,omitempty" yaml:"rai_policy_name,omitempty"` InvocationsModeration *InvocationsModeration `json:"invocationsModeration,omitempty" yaml:"invocations_moderation,omitempty"` } +// UnmarshalJSON accepts the legacy camelCase `raiPolicyName` alongside the +// current `rai_policy_name`. +// +// The field is spelled `rai_policy_name` everywhere it is authored or sent — +// agent.yaml, the azure.yaml service entry, and the service's own +// `rai_config` — but inline azure.yaml entries went through the JSON tag, which +// used to be camelCase. Projects written against that spelling keep deploying. +func (p *Policy) UnmarshalJSON(data []byte) error { + // The alias sheds the method set so this does not recurse. + type policyAlias Policy + var alias policyAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + *p = Policy(alias) + + if p.RaiPolicyName != "" { + return nil + } + var legacy struct { + RaiPolicyName string `json:"raiPolicyName"` + } + if err := json.Unmarshal(data, &legacy); err != nil { + return err + } + p.RaiPolicyName = legacy.RaiPolicyName + return nil +} + // ContainerAgent This represents a container based agent hosted by the provider/publisher. // The intent is to represent a container application that the user wants to run // in a hosted environment that the provider manages. @@ -476,15 +506,7 @@ const harnessTypeObsoleteAbbreviation = "ghcp" // tool type newer than this build still passes through. func (h *PromptHarness) UnmarshalYAML(value *yaml.Node) error { if value.Kind == yaml.ScalarNode { - replacement := value.Value - if replacement == harnessTypeObsoleteAbbreviation { - replacement = harnessTypeGitHubCopilotPreview - } - return fmt.Errorf( - "harness must be a block, not a string: replace `harness: %s` with\n"+ - " harness:\n"+ - " type: %s", - value.Value, replacement) + return errHarnessStringForm(value.Value) } if value.Kind != yaml.MappingNode { @@ -499,9 +521,7 @@ func (h *PromptHarness) UnmarshalYAML(value *yaml.Node) error { } if decoded.Type == harnessTypeObsoleteAbbreviation { - return fmt.Errorf( - "harness.type %q is no longer accepted: use %q", - harnessTypeObsoleteAbbreviation, harnessTypeGitHubCopilotPreview) + return errHarnessObsoleteType() } *h = PromptHarness(decoded) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go index 0fb7423f327..2265d99ec60 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go @@ -16,7 +16,7 @@ import ( ) // raiPolicyID is a representative RAI policy ARM resource ID, the value users -// put in `raiPolicyName`. +// put in `rai_policy_name`. const raiPolicyID = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/" + "my-rg/providers/Microsoft.CognitiveServices/accounts/my-account/raiPolicies/Microsoft.DefaultV2" @@ -38,8 +38,7 @@ func inlineAgentService(t *testing.T, values map[string]any) *azdext.ServiceConf // TestAgentPoliciesRoundTrip verifies governance policies survive a marshal into // the inline service properties and back, and that they are persisted under the -// camelCase `raiPolicyName` key that azure.yaml authors use — not the -// `rai_policy_name` key of the deprecated on-disk agent.yaml. +// `rai_policy_name` key the service, agent.yaml and azure.yaml all share. func TestAgentPoliciesRoundTrip(t *testing.T) { t.Parallel() @@ -55,9 +54,9 @@ func TestAgentPoliciesRoundTrip(t *testing.T) { require.Len(t, policies, 1) policy := policies[0].GetStructValue().GetFields() require.Equal(t, "rai_policy", policy["type"].GetStringValue()) - require.Equal(t, raiPolicyID, policy["raiPolicyName"].GetStringValue()) - require.NotContains(t, policy, "rai_policy_name", - "azure.yaml uses the camelCase raiPolicyName key") + require.Equal(t, raiPolicyID, policy["rai_policy_name"].GetStringValue()) + require.NotContains(t, policy, "raiPolicyName", + "azure.yaml uses the same rai_policy_name key as the service") svc := &azdext.ServiceConfig{ Name: "rai-agent", @@ -110,8 +109,8 @@ func TestAgentPoliciesReachRaiConfig(t *testing.T) { "name": "rai-agent", "policies": []any{ map[string]any{ - "type": "rai_policy", - "raiPolicyName": raiPolicyID, + "type": "rai_policy", + "rai_policy_name": raiPolicyID, }, }, } @@ -160,7 +159,7 @@ func TestAgentPoliciesNoRaiConfigWhenAbsent(t *testing.T) { // TestAgentPoliciesValidation verifies malformed policies authored inline in // azure.yaml are rejected, and that the missing-name error names the -// azure.yaml key (raiPolicyName) rather than only the agent.yaml one. +// `rai_policy_name` key. func TestAgentPoliciesValidation(t *testing.T) { t.Parallel() @@ -172,11 +171,11 @@ func TestAgentPoliciesValidation(t *testing.T) { { name: "missing policy name", policy: map[string]any{"type": "rai_policy"}, - wantErrSubst: "'raiPolicyName' in azure.yaml", + wantErrSubst: "requires a policy name ('rai_policy_name')", }, { name: "missing type", - policy: map[string]any{"raiPolicyName": raiPolicyID}, + policy: map[string]any{"rai_policy_name": raiPolicyID}, wantErrSubst: "policies[0] requires a type", }, { @@ -272,8 +271,8 @@ func TestAgentPoliciesInvocationsModerationReachesRaiConfig(t *testing.T) { "protocols": []any{map[string]any{"protocol": "invocations", "version": "1.0.0"}}, "policies": []any{ map[string]any{ - "type": "rai_policy", - "raiPolicyName": raiPolicyID, + "type": "rai_policy", + "rai_policy_name": raiPolicyID, "invocationsModeration": map[string]any{ "responseMode": "non_streaming", "inputPaths": []any{"$.input"}, @@ -353,7 +352,7 @@ func TestAgentPoliciesInvocationsModerationInlineValidation(t *testing.T) { "policies": []any{ map[string]any{ "type": "rai_policy", - "raiPolicyName": raiPolicyID, + "rai_policy_name": raiPolicyID, "invocationsModeration": test.moderation, }, }, @@ -379,8 +378,8 @@ func TestAgentPoliciesInvocationsModerationNonHostedInline(t *testing.T) { "name": "rai-agent", "policies": []any{ map[string]any{ - "type": "rai_policy", - "raiPolicyName": raiPolicyID, + "type": "rai_policy", + "rai_policy_name": raiPolicyID, "invocationsModeration": map[string]any{ "responseMode": "non_streaming", "inputPaths": []any{"$.input"}, @@ -404,9 +403,28 @@ func TestAgentPoliciesSingleRaiPolicyInline(t *testing.T) { "name": "rai-agent", "image": "myregistry.azurecr.io/agent:v1", "policies": []any{ - map[string]any{"type": "rai_policy", "raiPolicyName": raiPolicyID}, - map[string]any{"type": "rai_policy", "raiPolicyName": raiPolicyID + "-2"}, + map[string]any{"type": "rai_policy", "rai_policy_name": raiPolicyID}, + map[string]any{"type": "rai_policy", "rai_policy_name": raiPolicyID + "-2"}, }, })) require.ErrorContains(t, err, "only one is supported") } + +// TestAgentPoliciesLegacyRaiPolicyNameKey covers azure.yaml files written before +// the key was aligned with the service: inline entries used to be marshalled +// through the camelCase JSON tag, so `raiPolicyName` must keep deploying. +func TestAgentPoliciesLegacyRaiPolicyNameKey(t *testing.T) { + t.Parallel() + + agentDef, _, found, _, err := AgentDefinitionFromService(inlineAgentService(t, map[string]any{ + "kind": "hosted", + "name": "rai-agent", + "image": "myregistry.azurecr.io/agent:v1", + "policies": []any{ + map[string]any{"type": "rai_policy", "raiPolicyName": raiPolicyID}, + }, + })) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, raiPolicyID, agentDef.Policies[0].RaiPolicyName) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go index 7f94a567510..634f0da0257 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go @@ -343,7 +343,11 @@ func (p *AgentServiceTargetProvider) resolvePromptAgentGraph( env map[string]string, progress azdext.ProgressReporter, ) (map[string]any, error) { - agentDir := "" + // The skills/ and vector-assets/ convention folders sit next to the file + // that supplies the definition. With the definition inline on the service + // entry there is no such file, so they are anchored at the service + // directory instead — the same place `azd ai agent init` scaffolds them. + agentDir := p.servicePath if p.agentDefinitionPath != "" { agentDir = filepath.Dir(p.agentDefinitionPath) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_inline.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_inline.go new file mode 100644 index 00000000000..ea1db7ce827 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_inline.go @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "fmt" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/protobuf/types/known/structpb" +) + +// PromptAgentInline is a prompt-agent definition carried as flat service-level +// properties on the azure.ai.agent service entry — the same unified shape +// hosted and voice agents use, so every agent kind is authored in one file. +// +// It exists because [agent_yaml.PromptAgent]'s JSON tags are the Foundry wire +// format rather than the azure.yaml format. `memory` is tagged `json:"-"` there +// because the prompt-agent API defines no such field (azd provisions the store +// and injects a memory_search_preview tool instead), so marshaling a +// PromptAgent straight into service properties would silently drop an authored +// memory block. Re-declaring Memory at depth zero shadows the embedded field +// for azure.yaml while leaving the wire type untouched. +// +// HarnessSkills is deliberately not restored: it is resolved by the deploy +// graph from the skills/ folder and is never authored. +type PromptAgentInline struct { + agent_yaml.PromptAgent + + // Memory shadows the embedded PromptAgent.Memory so the authored block + // round-trips through azure.yaml. + Memory *agent_yaml.PromptMemory `json:"memory,omitempty"` +} + +// promptAgentToInline projects a PromptAgent into the shape written to +// azure.yaml, moving Memory onto the field that carries a JSON tag. +func promptAgentToInline(pa agent_yaml.PromptAgent) PromptAgentInline { + memory := pa.Memory + // Cleared so the inline value has one source of truth for the block. + pa.Memory = nil + return PromptAgentInline{PromptAgent: pa, Memory: memory} +} + +// toPromptAgent rebuilds an agent_yaml.PromptAgent from the inline definition. +func (d PromptAgentInline) toPromptAgent() agent_yaml.PromptAgent { + out := d.PromptAgent + out.Memory = d.Memory + return out +} + +// PromptAgentDefinitionToServiceProperties marshals a PromptAgent (kind: +// prompt) into the inline service-level properties written to azure.yaml. +// +// Prompt agents carry no container, image, or code configuration — the harness +// owns the runtime — so, unlike the container writer, there is no `container` +// block to split out and nothing lands on the core service fields. +func PromptAgentDefinitionToServiceProperties( + pa agent_yaml.PromptAgent, +) (*structpb.Struct, error) { + inline := promptAgentToInline(pa) + + defStruct, err := MarshalStruct(&inline) + if err != nil { + return nil, fmt.Errorf("marshaling prompt agent definition: %w", err) + } + + return defStruct, nil +} + +// PromptAgentFromResolvedService resolves a prompt agent definition from a +// service entry's inline (preferred) or legacy config properties. It returns the +// parsed PromptAgent and whether a prompt definition was found. Definitions of +// another kind — and services carrying none — return found=false with no error +// so callers fall through to the file-based path unchanged. +// +// File includes are expanded by resolveServiceProps before the kind is read, so +// a service whose definition lives behind `$ref:` resolves here too. +func PromptAgentFromResolvedService( + svc *azdext.ServiceConfig, + projectRoot string, +) (agent_yaml.PromptAgent, bool, error) { + candidates := []*structpb.Struct{ + svc.GetAdditionalProperties(), + svc.GetConfig(), + } + for _, props := range candidates { + if props == nil || len(props.GetFields()) == 0 { + continue + } + resolved, err := resolveServiceProps(props, svc.GetName(), projectRoot) + if err != nil { + return agent_yaml.PromptAgent{}, false, err + } + if !structHasKind(resolved) { + continue + } + if !strings.EqualFold(structKind(resolved), string(agent_yaml.AgentKindPrompt)) { + // A definition is present but it is not a prompt agent. + return agent_yaml.PromptAgent{}, false, nil + } + + // The authored blocks are checked before the decode so a typo is + // reported as a typo. UnmarshalYAML never runs on this route: core azd + // parsed azure.yaml and handed the properties over as protobuf. + if err := agent_yaml.ValidateInlinePromptAgent(resolved.AsMap()); err != nil { + return agent_yaml.PromptAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent %q is not a valid prompt agent: %s", svc.GetName(), err), + "correct the agent definition on the service entry in azure.yaml", + ) + } + + var inline PromptAgentInline + if err := UnmarshalStruct(resolved, &inline); err != nil { + return agent_yaml.PromptAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("prompt agent service config is not valid: %s", err), + "re-run `azd ai agent init` to regenerate the agent service entry", + ) + } + return inline.toPromptAgent(), true, nil + } + + return agent_yaml.PromptAgent{}, false, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_inline_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_inline_test.go new file mode 100644 index 00000000000..7feed46adab --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_inline_test.go @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// TestPromptAgentInlineRoundTripPreservesMemory is the regression test for the +// reason PromptAgentInline exists. +// +// agent_yaml.PromptAgent tags Memory json:"-" because the prompt-agent API +// defines no such field. Service properties round-trip through JSON, so +// marshaling a PromptAgent directly would drop an authored memory block with no +// error and no diagnostic — the agent would simply deploy without recall. +func TestPromptAgentInlineRoundTripPreservesMemory(t *testing.T) { + t.Parallel() + + original := agent_yaml.PromptAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindPrompt, + Name: "memory-agent", + }, + Model: "gpt-4.1-mini", + Instructions: "You are a helpful AI assistant.", + Memory: &agent_yaml.PromptMemory{ + Store: "conversation-store", + }, + } + + props, err := PromptAgentDefinitionToServiceProperties(original) + require.NoError(t, err) + require.Contains(t, props.AsMap(), "memory", "memory block must survive into azure.yaml") + + svc := &azdext.ServiceConfig{Name: "memory-agent", AdditionalProperties: props} + got, found, err := PromptAgentFromResolvedService(svc, t.TempDir()) + require.NoError(t, err) + require.True(t, found) + require.NotNil(t, got.Memory, "memory block must survive the round trip") + require.Equal(t, "conversation-store", got.Memory.Store) +} + +// TestPromptAgentInlineRoundTripPreservesDefinition covers the fields the deploy +// path reads, so a marshaling change that silently drops one is caught here +// rather than at deploy time. +func TestPromptAgentInlineRoundTripPreservesDefinition(t *testing.T) { + t.Parallel() + + original := agent_yaml.PromptAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindPrompt, + Name: "full-agent", + }, + Model: "gpt-4.1-mini", + Instructions: "Be concise.", + Harness: &agent_yaml.PromptHarness{ + Type: "github_copilot_preview", + }, + Tools: []any{map[string]any{"type": "code_interpreter"}}, + Connections: []agent_yaml.PromptConnection{ + {Name: "search", Category: "CognitiveSearch"}, + }, + } + + props, err := PromptAgentDefinitionToServiceProperties(original) + require.NoError(t, err) + + svc := &azdext.ServiceConfig{Name: "full-agent", AdditionalProperties: props} + got, found, err := PromptAgentFromResolvedService(svc, t.TempDir()) + require.NoError(t, err) + require.True(t, found) + + require.Equal(t, agent_yaml.AgentKindPrompt, got.Kind) + require.Equal(t, "full-agent", got.Name) + require.Equal(t, "gpt-4.1-mini", got.Model) + require.Equal(t, "Be concise.", got.Instructions) + require.NotNil(t, got.Harness) + require.Equal(t, "github_copilot_preview", got.Harness.Type) + require.Len(t, got.Tools, 1) + require.Len(t, got.Connections, 1) + require.Equal(t, "search", got.Connections[0].Name) + + // Never authored: the deploy graph resolves it from the skills/ folder. + require.Empty(t, got.HarnessSkills) +} + +// TestPromptAgentFromResolvedServiceIgnoresOtherKinds confirms a hosted or voice +// entry is reported as "not found" rather than as an error, so the hosted +// resolvers keep their turn. +func TestPromptAgentFromResolvedServiceIgnoresOtherKinds(t *testing.T) { + t.Parallel() + + for _, kind := range []string{"hosted", "prompt-voice"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + svc := &azdext.ServiceConfig{ + Name: "other", + AdditionalProperties: mustStruct(t, map[string]any{ + "kind": kind, + "name": "other", + }), + } + _, found, err := PromptAgentFromResolvedService(svc, t.TempDir()) + require.NoError(t, err) + require.False(t, found) + }) + } +} + +// TestPromptAgentFromResolvedServiceNoDefinition confirms an entry carrying no +// definition at all falls through quietly, which is what lets projects that +// still keep their definition in a file reach the file-based path. +func TestPromptAgentFromResolvedServiceNoDefinition(t *testing.T) { + t.Parallel() + + svc := &azdext.ServiceConfig{ + Name: "legacy", + Config: mustStruct(t, map[string]any{"promptAgent": map[string]any{"workspace": "w"}}), + } + _, found, err := PromptAgentFromResolvedService(svc, t.TempDir()) + require.NoError(t, err) + require.False(t, found) +} + +// TestPromptAgentInlineStrictValidation is the regression test for the +// validation gap the inline shape opens. +// +// The strict checks on harness: and memory: live in UnmarshalYAML, which never +// runs for an inline definition: core azd parses azure.yaml and hands the +// extension protobuf, which is decoded as JSON. Without the explicit validation +// pass these manifests would deploy an agent whose capabilities differ from what +// was authored. +func TestPromptAgentInlineStrictValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + props map[string]any + wantErr string + }{ + { + name: "harness as a string names the replacement block", + props: map[string]any{ + "kind": "prompt", + "name": "a", + "harness": "github_copilot_preview", + }, + wantErr: "harness must be a block, not a string", + }, + { + name: "obsolete harness string is upgraded in the suggestion", + props: map[string]any{ + "kind": "prompt", + "name": "a", + "harness": "ghcp", + }, + wantErr: "type: github_copilot_preview", + }, + { + name: "obsolete harness type is rejected by name", + props: map[string]any{ + "kind": "prompt", + "name": "a", + "harness": map[string]any{"type": "ghcp"}, + }, + wantErr: "no longer accepted", + }, + { + name: "harness typo binds nothing and is rejected", + props: map[string]any{ + "kind": "prompt", + "name": "a", + "harness": map[string]any{ + "type": "github_copilot_preview", + "builtin_tool": map[string]any{"excluded": []any{"bash"}}, + }, + }, + wantErr: "builtin_tool", + }, + { + name: "memory typo binds nothing and is rejected", + props: map[string]any{ + "kind": "prompt", + "name": "a", + "memory": map[string]any{"stores": "s"}, + }, + wantErr: "stores", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + svc := &azdext.ServiceConfig{Name: "a", AdditionalProperties: mustStruct(t, tt.props)} + _, _, err := PromptAgentFromResolvedService(svc, t.TempDir()) + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +// TestPromptAgentInlineAcceptsPassThroughTools confirms the forward-compatible +// fields stay forward-compatible: a tool type newer than this build must not be +// rejected by the strict pass. +func TestPromptAgentInlineAcceptsPassThroughTools(t *testing.T) { + t.Parallel() + + svc := &azdext.ServiceConfig{ + Name: "a", + AdditionalProperties: mustStruct(t, map[string]any{ + "kind": "prompt", + "name": "a", + "model": "gpt-4.1-mini", + "tools": []any{map[string]any{"type": "some_future_tool_preview", "unknown": true}}, + }), + } + + got, found, err := PromptAgentFromResolvedService(svc, t.TempDir()) + require.NoError(t, err) + require.True(t, found) + require.Len(t, got.Tools, 1) +} + +// TestResolvePromptAgentSettingsWithoutConfigBlock is the regression test for +// removing the promptAgent block from scaffolding: the harness target must +// resolve from the azd environment alone. +func TestResolvePromptAgentSettingsWithoutConfigBlock(t *testing.T) { + t.Parallel() + + env := map[string]string{ + "AZURE_SUBSCRIPTION_ID": "sub-1", + "AZURE_RESOURCE_GROUP": "rg-1", + "AZURE_AI_WORKSPACE": "acct@proj@AML", + "AZURE_AI_PROJECT_ENDPOINT": "https://proj.services.ai.azure.com/api/projects/p", + } + + settings, err := ResolvePromptAgentSettings(nil, env) + require.NoError(t, err) + require.Equal(t, "sub-1", settings.SubscriptionID) + require.Equal(t, "rg-1", settings.ResourceGroup) + require.Equal(t, "acct@proj@AML", settings.Workspace) + require.Equal(t, "https://proj.services.ai.azure.com/api/projects/p", settings.ProjectEndpoint) + require.NotEmpty(t, settings.BaseURL, "base URL comes from the built-in default") +} + +// TestResolvePromptAgentSettingsConfigBlockWins confirms a hand-authored block +// still overrides the environment, which is what keeps it useful as an escape +// hatch for the advanced knobs the environment does not carry. +func TestResolvePromptAgentSettingsConfigBlockWins(t *testing.T) { + t.Parallel() + + env := map[string]string{ + "AZURE_SUBSCRIPTION_ID": "sub-from-env", + "AZURE_RESOURCE_GROUP": "rg-from-env", + "AZURE_AI_WORKSPACE": "ws-from-env", + } + configured := &PromptAgentSettings{ + ResourceGroup: "rg-pinned", + APIVersion: "2099-01-01", + } + + settings, err := ResolvePromptAgentSettings(configured, env) + require.NoError(t, err) + require.Equal(t, "rg-pinned", settings.ResourceGroup, "authored value wins") + require.Equal(t, "sub-from-env", settings.SubscriptionID, "unset fields still fall back to the environment") + require.Equal(t, "2099-01-01", settings.APIVersion) +} + +// TestResolvePromptAgentSettingsExpandsLegacyRefs confirms projects scaffolded +// before the block was removed — whose every field is a ${VAR} reference — keep +// resolving to the same values. +func TestResolvePromptAgentSettingsExpandsLegacyRefs(t *testing.T) { + t.Parallel() + + env := map[string]string{ + "AZURE_SUBSCRIPTION_ID": "sub-1", + "AZURE_RESOURCE_GROUP": "rg-1", + "AZURE_AI_WORKSPACE": "ws-1", + } + legacy := &PromptAgentSettings{ + SubscriptionID: "${AZURE_SUBSCRIPTION_ID}", + ResourceGroup: "${AZURE_RESOURCE_GROUP}", + Workspace: "${AZURE_AI_WORKSPACE}", + } + + settings, err := ResolvePromptAgentSettings(legacy, env) + require.NoError(t, err) + require.Equal(t, "sub-1", settings.SubscriptionID) + require.Equal(t, "rg-1", settings.ResourceGroup) + require.Equal(t, "ws-1", settings.Workspace) +} + +// TestServiceIsPromptAgent covers both the inline marker and the pre-inline +// promptAgent block, since projects scaffolded before this change declare no +// kind on the service entry. +func TestServiceIsPromptAgent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + svc *azdext.ServiceConfig + want bool + }{ + { + name: "inline prompt definition", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"kind": "prompt", "name": "a"}), + }, + want: true, + }, + { + name: "inline hosted definition", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"kind": "hosted", "name": "a"}), + }, + want: false, + }, + { + name: "inline voice definition", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"kind": "prompt-voice", "name": "a"}), + }, + want: false, + }, + { + name: "pre-inline promptAgent block", + svc: &azdext.ServiceConfig{ + Config: mustStruct(t, map[string]any{ + "promptAgent": map[string]any{"workspace": "ws"}, + }), + }, + want: true, + }, + { + name: "no definition and no block", + svc: &azdext.ServiceConfig{}, + want: false, + }, + { + name: "nil service", + svc: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, ServiceIsPromptAgent(tt.svc)) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 1a2c61c258e..6fa161d7862 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -304,11 +304,17 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er return err } - // Prompt (kind=managed) agents target the managed harness, not an ARM + // Recorded before the prompt-agent branch below returns: with the definition + // carried inline there is no agent.yaml to anchor the skills/ and + // vector-assets/ convention folders, so the service directory is what locates + // them. + p.servicePath = fullPath + + // Prompt (kind=prompt) agents target the managed harness, not an ARM // Foundry project. They self-authenticate via the harness client and carry // their entire deploy target in the service config, so skip the // subscription/tenant/credential resolution the hosted path needs. - if serviceIsPromptAgent(p.serviceConfig) { + if ServiceIsPromptAgent(p.serviceConfig) { return p.resolveAgentDefinitionPath(proj.Project.Path, servicePath, fullPath, declaredRef) } @@ -359,8 +365,6 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er } p.credential = cred - p.servicePath = fullPath - return p.resolveAgentDefinitionPath(proj.Project.Path, servicePath, fullPath, declaredRef) } @@ -407,7 +411,7 @@ func (p *AgentServiceTargetProvider) resolveAgentDefinitionPath( // nothing more. A prompt agent does: it reads the raw YAML and anchors the // skills/ and vector-assets/ convention folders next to the file, so record // where the file actually lives. - if declaredRef != "" && serviceIsPromptAgent(p.serviceConfig) { + if declaredRef != "" && ServiceIsPromptAgent(p.serviceConfig) { resolved, err := resolveDeclaredRefPath(projectPath, declaredRef, p.serviceConfig.Name) if err != nil { return err @@ -441,6 +445,19 @@ func (p *AgentServiceTargetProvider) resolveAgentDefinitionPath( return nil } + // The call above answers for hosted agents only: it reports found=false when + // the entry declares a different kind. Ask the prompt resolver as well, so an + // inline prompt agent is not sent looking for a file it does not have. + if _, found, promptErr := PromptAgentFromResolvedService( + p.serviceConfig, + projectPath, + ); promptErr != nil { + return promptErr + } else if found { + p.deployContextReady = true + return nil + } + // Legacy shape: look for agent.yaml or agent.yml in the service directory root agentYamlPath, err := paths.JoinAllowRoot(projectPath, servicePath, "agent.yaml") if err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go index fe4694c69da..f946fddffe8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go @@ -26,13 +26,31 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/braydonk/yaml" + "google.golang.org/protobuf/types/known/structpb" ) -// serviceIsPromptAgent reports whether the service config describes a prompt -// (kind=managed) agent. Prompt agents carry a populated `promptAgent` block in -// their azure.yaml service config; hosted/workflow agents leave it nil. -func serviceIsPromptAgent(serviceConfig *azdext.ServiceConfig) bool { - if serviceConfig == nil || serviceConfig.Config == nil { +// ServiceIsPromptAgent reports whether the service config describes a prompt +// (kind=prompt) agent. +// +// The definition is carried inline on the service entry, so `kind` names the +// flavor directly. A `$ref:` include is merged onto the entry by +// resolveServiceConfig before this runs, so a definition that lives in its own +// file is classified the same way. +func ServiceIsPromptAgent(serviceConfig *azdext.ServiceConfig) bool { + if serviceConfig == nil { + return false + } + for _, props := range []*structpb.Struct{ + serviceConfig.GetAdditionalProperties(), + serviceConfig.GetConfig(), + } { + if kind := structKind(props); kind != "" { + return strings.EqualFold(kind, string(agent_yaml.AgentKindPrompt)) + } + } + // Projects scaffolded before the definition moved inline declare no kind on + // the service entry and are identified by their promptAgent config block. + if serviceConfig.Config == nil { return false } var cfg ServiceTargetAgentConfig @@ -45,7 +63,7 @@ func serviceIsPromptAgent(serviceConfig *azdext.ServiceConfig) bool { // isPromptAgentService reports whether the provider's current service is a // prompt agent. func (p *AgentServiceTargetProvider) isPromptAgentService() bool { - return serviceIsPromptAgent(p.serviceConfig) + return ServiceIsPromptAgent(p.serviceConfig) } // promptAgentSettings extracts and validates the prompt-agent harness settings @@ -71,16 +89,19 @@ func (p *AgentServiceTargetProvider) promptAgentSettings(env map[string]string) return ResolvePromptAgentSettings(cfg.PromptAgent, env) } -// ResolvePromptAgentSettings turns a raw promptAgent block from azure.yaml into -// settings that can address the harness: ${VAR} references are expanded against -// env, the result is layered over the defaults, process-environment overrides -// are applied, and the whole is validated. +// ResolvePromptAgentSettings produces the settings that address the harness. // -// Every caller that talks to the harness must go through this. The block is -// written with ${...} references so azure.yaml stays portable, which means the -// raw config carries literal "${AZURE_AI_PROJECT_ENDPOINT}" strings — usable as -// a URL only after expansion. Skipping this step fails at the point of use with -// a message about a malformed URL rather than a missing variable. +// The Foundry target is read from the azd environment, which is the only thing +// that knows it: `azd provision` writes the subscription, resource group, +// workspace, and project endpoint there, and they change per environment. That +// is why azure.yaml carries no promptAgent block — the values would be either a +// copy of the environment or a set of ${VAR} references pointing back at it. +// +// A hand-authored promptAgent block still wins, layered on top, so a developer +// can pin a field or set one of the advanced knobs (apiVersion, modelEndpoint) +// that the environment does not carry. Its ${VAR} references are expanded +// against the same environment first, keeping older projects working unchanged. +// Process-environment AZD_MANAGED_AGENT_* overrides are applied last. func ResolvePromptAgentSettings( configured *PromptAgentSettings, env map[string]string, @@ -90,6 +111,7 @@ func ResolvePromptAgentSettings( return nil, err } settings := DefaultPromptAgentSettings() + settings.overlay(promptAgentSettingsFromEnv(env)) settings.overlay(expanded) settings.ApplyEnvOverrides() if err := settings.Validate(); err != nil { @@ -98,6 +120,24 @@ func ResolvePromptAgentSettings( return &settings, nil } +// promptAgentSettingsFromEnv reads the Foundry target out of the azd +// environment using the standard variable names `azd provision` records. +// +// Only the fields the environment actually knows are returned; an unset +// variable is left empty so overlay() keeps the default in place, which is what +// lets a project be cloned and inspected before it has been provisioned. +func promptAgentSettingsFromEnv(env map[string]string) *PromptAgentSettings { + if env == nil { + return nil + } + return &PromptAgentSettings{ + SubscriptionID: strings.TrimSpace(env["AZURE_SUBSCRIPTION_ID"]), + ResourceGroup: strings.TrimSpace(env["AZURE_RESOURCE_GROUP"]), + Workspace: strings.TrimSpace(env["AZURE_AI_WORKSPACE"]), + ProjectEndpoint: strings.TrimSpace(env["AZURE_AI_PROJECT_ENDPOINT"]), + } +} + // expandPromptAgentSettings returns a copy of src with ${VAR} references in // every field resolved against env, falling back to the process environment for // variables the azd environment does not define. A nil src returns nil. @@ -264,14 +304,37 @@ func (p *AgentServiceTargetProvider) resolvedPromptAgentSettings( return settings, nil } -// loadPromptAgentDefinition reads the agent.yaml as a bare PromptAgent. +// loadPromptAgentDefinition returns the service's prompt-agent definition. +// +// The definition is normally inline on the azure.yaml service entry, which is +// what `azd ai agent init` scaffolds. agentDefinitionPath is set only when the +// definition lives in its own file — a `$ref:` include, the AGENT_DEFINITION_PATH +// override, or the legacy agent.yaml convention — and that file is then the +// authority, because it is also what anchors the skills/ and vector-assets/ +// convention folders. func (p *AgentServiceTargetProvider) loadPromptAgentDefinition() (agent_yaml.PromptAgent, error) { + if p.agentDefinitionPath == "" { + promptDef, found, err := PromptAgentFromResolvedService(p.serviceConfig, p.projectPath) + if err != nil { + return agent_yaml.PromptAgent{}, err + } + if !found { + return agent_yaml.PromptAgent{}, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("service %q carries no prompt agent definition", p.serviceConfig.GetName()), + "add the agent definition to the service entry in azure.yaml, "+ + "or re-run `azd ai agent init`", + ) + } + return promptDef, nil + } + data, err := os.ReadFile(p.agentDefinitionPath) if err != nil { return agent_yaml.PromptAgent{}, exterrors.Validation( exterrors.CodeInvalidAgentManifest, fmt.Sprintf("failed to read agent manifest file: %s", err), - "verify the agent.yaml file exists and is readable", + "verify the agent definition file exists and is readable", ) } if err := validatePromptAgentRawFields(data); err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go index cbec2c695ee..e2e6d9eed2e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -755,10 +755,12 @@ func agentNeedsAcr(a agentBlock) bool { if a.CodeConfiguration != nil || strings.TrimSpace(a.Image) != "" { return false } - // A promptAgent: block means Foundry runs the agent from its definition; there - // is nothing to build. `azd ai agent init` omits kind: from the service config, - // so without this check the default-to-hosted fallback below would provision an - // ACR (and an AcrPull role assignment) the prompt agent never uses. + // A promptAgent: block is the pre-inline marker for a prompt agent, whose + // service entry carried no kind:. Foundry runs those from their definition, + // so without this check the default-to-hosted fallback below would provision + // an ACR (and an AcrPull role assignment) the agent never uses. Entries + // scaffolded since the definition moved inline declare kind: prompt and are + // handled by the check below. if a.PromptAgent != nil { return false }