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/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 50bbb9a6c75..1de44c2c46a 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -83,3 +83,9 @@ words: - Bhadauria # Test infrastructure - recordproxy + # Managed agent (Foundry PES / vienna harness) terms + - vienna + - azureml + - cognitiveservices + - fdp + - PES diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index b2957cc72f2..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.41-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/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 ec0941cca04..9c6448fc781 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 acdb24a096a..75fa9c34120 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 @@ -946,6 +952,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. @@ -1305,6 +1351,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/')") @@ -1327,6 +1376,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..41fd60c4ae0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go @@ -0,0 +1,525 @@ +// 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 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) + managedAgent.AgentDefinition.Description = &desc + } + if err := writeManagedAgentYAML(serviceRelPath, &managedAgent); err != nil { + 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 + } + + // 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 +} + +// 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, + 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) + } + + // 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 != "." { + 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/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/invoke.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go index 9efd991596f..4ac16d75db1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -351,6 +351,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 f075702deed..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,11 +57,25 @@ 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: - if err := populateContainerSettings(ctx, azdClient, svc); err != nil { - return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) + 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 + // (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) @@ -69,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 } @@ -84,6 +117,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", @@ -157,6 +198,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) } @@ -334,6 +381,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) } @@ -342,6 +396,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 49f1c6f0bb3..806c9b1ba63 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 7d9d3ae3e22..4561115f0a8 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..00b74b35d7b --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go @@ -0,0 +1,281 @@ +// 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 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 + yamlContent string + wantSubstr string + shouldError bool + }{ + { + name: "missing model", + yamlContent: ` +name: n +kind: managed +instructions: ok +`, + wantSubstr: "model", + shouldError: true, + }, + { + name: "missing inline instructions is allowed (may come from instructions.md)", + yamlContent: ` +name: n +kind: managed +model: gpt-4.1-mini +`, + shouldError: false, + }, + { + 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) + } +} + +// 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 e9720dabcb5..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 @@ -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,65 @@ 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...) + } + + // 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 { + 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..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 @@ -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 } @@ -173,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) } @@ -418,6 +438,39 @@ 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") + } + // 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: + 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/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 8038d30ba06..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 @@ -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, } } @@ -38,6 +44,8 @@ const ( ResourceKindTool ResourceKind = "tool" ResourceKindToolbox ResourceKind = "toolbox" ResourceKindConnection ResourceKind = "connection" + ResourceKindSkill ResourceKind = "skill" + ResourceKindFile ResourceKind = "file" ) type ToolKind string @@ -231,6 +239,105 @@ 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. + // 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"` + + // 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"` + + // 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. // 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 @@ -762,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/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/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/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/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index e03dafa4328..592af6ab505 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 @@ -215,6 +215,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, @@ -263,6 +272,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 @@ -290,19 +308,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", ) } @@ -339,6 +357,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, @@ -404,6 +432,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 @@ -463,6 +512,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") @@ -566,6 +621,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 @@ -971,6 +1031,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..a05da716058 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go @@ -0,0 +1,651 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "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. +// +// 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 { + 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", + ) + } + 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( + 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", + ) + } + + // 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 +// 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) + } + } + + // 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( + 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/version.txt b/cli/azd/extensions/azure.ai.agents/version.txt index de4db352fba..1b74d47f683 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.43-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..5c06f52d011 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,170 @@ "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" + } + ] + }, + { + "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" + } + ] } ] }, @@ -5023,7 +5187,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd concurx [options]", + "usage": "azd concurx \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -5082,7 +5246,7 @@ "custom-commands", "metadata" ], - "usage": "azd concurx [options]", + "usage": "azd concurx \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -5141,7 +5305,7 @@ "custom-commands", "metadata" ], - "usage": "azd concurx [options]", + "usage": "azd concurx \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -5207,7 +5371,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5276,7 +5440,7 @@ "capabilities": [ "custom-commands" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5346,7 +5510,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5416,7 +5580,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5486,7 +5650,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5556,7 +5720,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5626,7 +5790,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5696,7 +5860,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai finetuning [options]", + "usage": "azd ai finetuning \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5774,7 +5938,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models [options]", + "usage": "azd ai models \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5859,7 +6023,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models [options]", + "usage": "azd ai models \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -5944,7 +6108,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models [options]", + "usage": "azd ai models \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -6029,7 +6193,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models [options]", + "usage": "azd ai models \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -6114,7 +6278,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models [options]", + "usage": "azd ai models \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -6204,7 +6368,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai models [options]", + "usage": "azd ai models \u003ccommand\u003e [options]", "examples": [ { "name": "init", @@ -6302,12 +6466,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 +6531,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 +6600,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 +6678,7 @@ "versions": [ { "version": "0.1.0-preview", - "requiredAzdVersion": ">1.25.2", + "requiredAzdVersion": "\u003e1.25.2", "usage": "", "examples": null, "dependencies": [ @@ -6566,7 +6730,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai connection [options]", + "usage": "azd ai connection \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -6625,7 +6789,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai connection [options]", + "usage": "azd ai connection \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -6684,7 +6848,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai connection [options]", + "usage": "azd ai connection \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -6755,7 +6919,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai project [options]", + "usage": "azd ai project \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -6826,7 +6990,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai routine [options]", + "usage": "azd ai routine \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -6893,12 +7057,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 +7133,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 +7225,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai toolbox [options]", + "usage": "azd ai toolbox \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -7120,7 +7284,7 @@ "custom-commands", "metadata" ], - "usage": "azd ai toolbox [options]", + "usage": "azd ai toolbox \u003ccommand\u003e [options]", "examples": null, "artifacts": { "darwin/amd64": { @@ -7180,4 +7344,4 @@ ] } ] -} +} \ No newline at end of file