From cdef10b3e6df22caed0669ad56d9678a9e8b5dfc Mon Sep 17 00:00:00 2001 From: Kshitij Chawla Date: Thu, 25 Jun 2026 22:47:58 +0530 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 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 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 4/5] 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 5/5] 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 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

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