Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cli/azd/extensions/azure.ai.agents/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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!
Expand Down
6 changes: 6 additions & 0 deletions cli/azd/extensions/azure.ai.agents/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,9 @@ words:
- Bhadauria
# Test infrastructure
- recordproxy
# Managed agent (Foundry PES / vienna harness) terms
- vienna
- azureml
- cognitiveservices
- fdp
- PES
2 changes: 1 addition & 1 deletion cli/azd/extensions/azure.ai.agents/extension.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ displayName: Foundry agents (Preview)
description: Ship agents with Microsoft Foundry from your terminal. (Preview)
usage: azd ai agent <command> [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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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),
Expand All @@ -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),
Expand Down
95 changes: 93 additions & 2 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
57 changes: 57 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go
Original file line number Diff line number Diff line change
@@ -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",
),
)
}
17 changes: 16 additions & 1 deletion cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type initFlags struct {
model string
manifestPointer string
agentName string
description string
src string
env string
protocols []string
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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/<agent-id>')")

Expand All @@ -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
}

Expand Down
Loading
Loading