diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 96ca432d72e..cf975cb2f5b 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -1,5 +1,11 @@ import: ../../../.vscode/cspell.global.yaml words: + - braydonk + - osutil + - upserted + - upserting + - upserts + - yamlnode - agentcopilot - agentdetect - Authenticode @@ -9,6 +15,9 @@ words: - lightspeed - runewidth - toplevel + - myacr + - myconn + - myorg - azcloud - azdext - azdxignore diff --git a/cli/azd/cmd/telemetry_coverage_test.go b/cli/azd/cmd/telemetry_coverage_test.go index be763ddf518..ddd3e6cee6f 100644 --- a/cli/azd/cmd/telemetry_coverage_test.go +++ b/cli/azd/cmd/telemetry_coverage_test.go @@ -81,6 +81,14 @@ func TestTelemetryFieldConstants(t *testing.T) { } }) + // Project config telemetry fields + t.Run("ProjectFields", func(t *testing.T) { + t.Parallel() + kv := fields.FoundryAgentLegacyConfigKey.Bool(true) + require.Equal(t, "foundry.agent.legacy_config_shape", string(kv.Key)) + require.True(t, kv.Value.AsBool()) + }) + // Tool command telemetry fields t.Run("ToolFields", func(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 2bc4d5c517e..8b2ca4a64d9 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -5,6 +5,8 @@ ### Features Added - `azd ai agent init` now writes each Foundry resource as its own `azure.yaml` service entry instead of bundling everything into the agent service. Model deployments become a single `azure.ai.project` service, each connection becomes an `azure.ai.connection` service, and each toolbox becomes an `azure.ai.toolbox` service, all wired to the agent through `uses:`. The agents extension registers the `azure.ai.project`, `azure.ai.connection`, and `azure.ai.toolbox` service-target hosts itself as no-ops (the resources are created by Bicep at provision time), so only this extension needs to be installed for `azd up`/`azd deploy` to walk the new service entries. Provisioning behavior is unchanged: the agent extension re-sources deployments, connections, and toolboxes from the sibling services when setting provisioning environment variables and creating toolsets, falling back to a pre-split `azure.yaml` that still bundles them on the agent service so existing projects keep provisioning without re-running `init`. +- The `azure.ai.project`, `azure.ai.connection`, and `azure.ai.toolbox` hosts are now owned by their sibling extensions (`azure.ai.projects`, `azure.ai.connections`, `azure.ai.toolboxes`) as real deploy-time service targets. The agents extension no longer registers them as no-op hosts, and toolboxes are reconciled at `azd deploy` by the `azure.ai.toolbox` target rather than created during `azd provision`. +- `azd provision` now connects to an existing Foundry project when the `azure.ai.project` service sets `endpoint:` (bring-your-own) instead of failing with a brownfield error, and `azd down` leaves a bring-your-own project in place because azd did not create it. ## 0.1.41-preview (2026-06-19) diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index 9031eac6efc..42155d8b3db 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -13,6 +13,41 @@ Use `--no-inspector` to run only the local agent process: azd ai agent run --no-inspector ``` +## Migrating Legacy Agent Configuration + +New Foundry agent projects keep the agent definition directly on the +`azure.ai.agent` service entry in `azure.yaml`. Older projects may still have the +definition in an `agent.yaml` file or under the service's `config:` block. Those +legacy shapes continue to work during the migration window, but azd prints a +deprecation warning when it loads them. + +To migrate, re-run `azd ai agent init` from the project root and keep the +generated `azure.yaml` service entry. After confirming `azd deploy` still works, +remove the old `agent.yaml` or nested `config:` definition. + +Before: + +```yaml +services: + my-agent: + host: azure.ai.agent + project: . + config: + kind: hosted + description: My hosted agent +``` + +After: + +```yaml +services: + my-agent: + host: azure.ai.agent + project: . + kind: hosted + description: My hosted agent +``` + ## Private networking for `host: microsoft.foundry` Foundry services can be provisioned as network-secured, VNet-bound accounts by diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/endpoint_show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/endpoint_show.go index ad20cbd1cd8..48e64fa6d79 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/endpoint_show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/endpoint_show.go @@ -12,12 +12,10 @@ import ( "text/tabwriter" "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" "github.com/spf13/cobra" - goyaml "go.yaml.in/yaml/v3" ) type endpointShowFlags struct { @@ -83,19 +81,14 @@ func runEndpointShow( return err } - // Read agent.yaml to get agent name. - agentYamlPath, err := paths.JoinAllowRoot(proj.Path, svc.RelativePath, "agent.yaml") + // Resolve the agent definition (inline on the service entry, or a legacy + // agent.yaml on disk) to get the agent name. + agentDef, _, source, err := project.LoadAgentDefinition(svc, proj.Path) if err != nil { - return fmt.Errorf("invalid agent.yaml path: %w", err) + return fmt.Errorf("failed to resolve agent definition: %w", err) } - data, err := os.ReadFile(agentYamlPath) //nolint:gosec // path validated by JoinAllowRoot - if err != nil { - return fmt.Errorf("failed to read agent.yaml: %w", err) - } - - var agentDef agent_yaml.ContainerAgent - if err := goyaml.Unmarshal(data, &agentDef); err != nil { - return fmt.Errorf("failed to parse agent.yaml: %w", err) + if source.IsLegacy() { + project.WarnLegacyAgentShape(source) } // Resolve endpoint and create client. 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 09dad9ee5ad..5e49a58a7d7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go @@ -26,7 +26,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/google/uuid" - "go.yaml.in/yaml/v3" "golang.org/x/term" ) @@ -707,6 +706,9 @@ type ServiceRunContext struct { ServiceName string // the resolved service name (from azure.yaml) ProjectDir string // absolute path to the service source directory StartupCommand string // startupCommand from AdditionalProperties (may be empty) + // Definition is the resolved agent definition (from the inline azure.yaml + // entry or a legacy agent.yaml). It is nil when no definition can be resolved. + Definition *agent_yaml.ContainerAgent } // resolveServiceRunContext queries the azd project to find the matching azure.ai.agent @@ -727,10 +729,15 @@ func resolveServiceRunContext(ctx context.Context, azdClient *azdext.AzdClient, } var startupCmd string - if svc.Config != nil { - var agentConfig projectpkg.ServiceTargetAgentConfig - if err := projectpkg.UnmarshalStruct(svc.Config, &agentConfig); err == nil { - startupCmd = agentConfig.StartupCommand + if agentConfig, cfgErr := projectpkg.LoadServiceTargetAgentConfig(svc); cfgErr == nil { + startupCmd = agentConfig.StartupCommand + } + + var definition *agent_yaml.ContainerAgent + if def, _, source, defErr := projectpkg.LoadAgentDefinition(svc, project.Path); defErr == nil { + definition = &def + if source.IsLegacy() { + projectpkg.WarnLegacyAgentShape(source) } } @@ -738,6 +745,7 @@ func resolveServiceRunContext(ctx context.Context, azdClient *azdext.AzdClient, ServiceName: svc.Name, ProjectDir: projectDir, StartupCommand: startupCmd, + Definition: definition, }, nil } @@ -797,7 +805,7 @@ func resolveAgentProtocol( name string, noPrompt bool, ) (agent_api.AgentProtocol, error) { - svc, project, err := resolveAgentService(ctx, azdClient, name, noPrompt) + svc, proj, err := resolveAgentService(ctx, azdClient, name, noPrompt) if err != nil { return "", exterrors.Validation( exterrors.CodeInvalidParameter, @@ -809,56 +817,42 @@ func resolveAgentProtocol( ) } - agentYamlPath, err := paths.JoinAllowRoot(project.Path, svc.RelativePath, "agent.yaml") + hosted, isHosted, source, err := projectpkg.LoadAgentDefinition(svc, proj.Path) if err != nil { return "", exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("invalid service path for %s: %s", svc.Name, err), - "update azure.yaml so the agent service path stays within the project directory", + exterrors.CodeInvalidParameter, + fmt.Sprintf("could not resolve the agent definition for %s: %s", svc.Name, err), + "ensure the agent definition is present in azure.yaml or run `azd ai agent init`", ) } - return protocolFromAgentYaml(agentYamlPath) + if source.IsLegacy() { + projectpkg.WarnLegacyAgentShape(source) + } + if !isHosted { + return "", exterrors.Validation( + exterrors.CodeUnsupportedAgentKind, + fmt.Sprintf("agent service %s is not a hosted agent", svc.Name), + "only hosted agents can be invoked", + ) + } + + return protocolFromContainerAgent(hosted) } -// protocolFromAgentYaml reads and parses the agent.yaml file at the given path -// and extracts the protocol to use for invocation. Returns an error with a -// contextual suggestion when the file cannot be read, parsed, or does not -// declare exactly one invocable protocol. +// protocolFromContainerAgent extracts the protocol to use for invocation from a +// resolved agent definition. Returns an error with a contextual suggestion when +// the definition does not declare exactly one invocable protocol. // // When multiple protocols are declared (e.g. "responses" + "a2a"), the caller // must use --protocol to disambiguate. -func protocolFromAgentYaml( - agentYamlPath string, +func protocolFromContainerAgent( + hosted agent_yaml.ContainerAgent, ) (agent_api.AgentProtocol, error) { - data, err := os.ReadFile(agentYamlPath) //nolint:gosec // G304: path constructed from azd project root - if err != nil { - return "", exterrors.Validation( - exterrors.CodeInvalidParameter, - fmt.Sprintf( - "could not read agent.yaml at %s: %s", - agentYamlPath, err, - ), - "ensure agent.yaml exists in the azd service directory", - ) - } - - var hosted agent_yaml.ContainerAgent - if err := yaml.Unmarshal(data, &hosted); err != nil { - return "", exterrors.Validation( - exterrors.CodeInvalidParameter, - fmt.Sprintf( - "could not parse agent.yaml at %s: %s", - agentYamlPath, err, - ), - "fix the agent.yaml syntax", - ) - } - if len(hosted.Protocols) == 0 { return "", exterrors.Validation( exterrors.CodeInvalidParameter, - "agent.yaml does not declare any protocols", - "add a protocols section to agent.yaml", + "the agent definition does not declare any protocols", + "add a protocols section to the agent definition", ) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go index c80c92161b3..7e564840d6a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go @@ -9,8 +9,11 @@ import ( "strings" "testing" + "azureaiagent/internal/pkg/agents/agent_yaml" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/require" + goyaml "go.yaml.in/yaml/v3" ) func TestDetectStartupCommand(t *testing.T) { @@ -204,18 +207,6 @@ func TestProtocolFromAgentYaml(t *testing.T) { yaml: "protocols:\n - protocol: invocations\n version: \"1.0\"\n", wantProto: "invocations", }, - { - name: "no file", - noFile: true, - wantErr: true, - errContain: "could not read agent.yaml", - }, - { - name: "invalid yaml", - yaml: "protocols: [[[invalid", - wantErr: true, - errContain: "could not parse agent.yaml", - }, { name: "no protocols field", yaml: "name: my-agent\n", @@ -268,18 +259,10 @@ func TestProtocolFromAgentYaml(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - dir := t.TempDir() - yamlPath := filepath.Join(dir, "agent.yaml") - - if !tt.noFile { - if err := os.WriteFile( - yamlPath, []byte(tt.yaml), 0600, - ); err != nil { - t.Fatalf("failed to write agent.yaml: %v", err) - } - } + var agentDef agent_yaml.ContainerAgent + require.NoError(t, goyaml.Unmarshal([]byte(tt.yaml), &agentDef)) - got, err := protocolFromAgentYaml(yamlPath) + got, err := protocolFromContainerAgent(agentDef) if tt.wantErr { 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 7ca09d48a9e..e6a64dcb291 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -4,7 +4,6 @@ package cmd import ( - "bytes" "context" "crypto/rand" "encoding/hex" @@ -1672,9 +1671,11 @@ func (a *InitAction) Run(ctx context.Context) error { return err } - // Write the final agent.yaml to disk (after deployment names have been injected) - if err := writeAgentDefinitionFile(targetDir, agentManifest); err != nil { - return fmt.Errorf("writing agent definition: %w", err) + // Generate .agentignore. The agent definition now lives in azure.yaml, + // not in an on-disk agent.yaml, but .agentignore is still used to scope + // code-deploy ZIP packaging. + if err := writeAgentIgnoreFile(targetDir); err != nil { + return fmt.Errorf("writing .agentignore: %w", err) } // Add the agent to the azd project (azure.yaml) services @@ -2794,29 +2795,11 @@ func (a *InitAction) downloadAgentYaml( return agentManifest, targetDir, nil } -// writeAgentDefinitionFile writes the agent definition to disk as agent.yaml in targetDir. -// This should be called after all parameter/deployment injection is complete so the on-disk -// file has fully resolved values (no `{{...}}` placeholders). -func writeAgentDefinitionFile(targetDir string, agentManifest *agent_yaml.AgentManifest) error { - content, err := yaml.Marshal(agentManifest.Template) - if err != nil { - return fmt.Errorf("marshaling agent manifest to YAML: %w", err) - } - - annotation := "# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml" - agentFileContents := bytes.NewBufferString(annotation + "\n\n") - if _, err = agentFileContents.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, agentFileContents.Bytes(), osutil.PermissionFile); err != nil { - return fmt.Errorf("saving file to %s: %w", filePath, err) - } - - log.Printf("Processed agent.yaml at %s", filePath) - - // Generate .agentignore if it doesn't already exist +// writeAgentIgnoreFile generates a default .agentignore in targetDir if one does +// not already exist. The agent definition itself is no longer written to disk — +// it lives as service-level properties in azure.yaml — but .agentignore is still +// used to scope which files are included in code-deploy ZIP packaging. +func writeAgentIgnoreFile(targetDir string) error { agentIgnorePath := filepath.Join(targetDir, ".agentignore") if _, err := os.Stat(agentIgnorePath); os.IsNotExist(err) { if err := os.WriteFile(agentIgnorePath, []byte(project.DefaultAgentIgnoreContent()), osutil.PermissionFile); err != nil { @@ -2966,17 +2949,32 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa agentConfig.Connections = nil agentConfig.Toolboxes = nil - var agentConfigStruct *structpb.Struct - if agentConfigStruct, err = project.MarshalStruct(&agentConfig); err != nil { - return fmt.Errorf("failed to marshal agent config: %w", err) + // The agent definition (formerly written to agent.yaml) now lives as + // service-level properties on the azure.ai.agent entry. Rebuild the full + // container agent from the manifest template so it can be embedded inline + // alongside the remaining agent config (container, tool connections, + // startup command). + var containerDef agent_yaml.ContainerAgent + templateYAML, err := yaml.Marshal(agentManifest.Template) + if err != nil { + return fmt.Errorf("marshaling agent definition: %w", err) + } + if err := yaml.Unmarshal(templateYAML, &containerDef); err != nil { + return fmt.Errorf("parsing agent definition: %w", err) + } + + agentProps, err := project.AgentDefinitionToServiceProperties(containerDef, &agentConfig) + if err != nil { + return err } serviceConfig := &azdext.ServiceConfig{ - Name: a.serviceNameOverride, - RelativePath: targetDir, - Host: AiAgentHost, - Language: "docker", - Config: agentConfigStruct, + Name: a.serviceNameOverride, + RelativePath: targetDir, + Host: AiAgentHost, + Language: "docker", + Image: containerDef.Image, + AdditionalProperties: agentProps, } // For hosted agents, configure Docker or code deploy settings diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index 4c6b64b76f6..c7c36971b4a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -22,8 +22,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/fatih/color" - "google.golang.org/protobuf/types/known/structpb" - "gopkg.in/yaml.v3" ) type InitFromCodeAction struct { @@ -128,28 +126,23 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { if localDefinition != nil { - // Write the definition to a file in the src directory - _, err := a.writeDefinitionToSrcDir(localDefinition, srcDir) - if err != nil { - return fmt.Errorf("failed to write definition to src directory: %w", err) + // Generate .agentignore. The agent definition is written into the + // azure.yaml service entry below, not to an on-disk agent.yaml. + if err := a.writeAgentIgnoreToSrcDir(srcDir); err != nil { + return fmt.Errorf("failed to write .agentignore: %w", err) } // Add the agent to the azd project (azure.yaml) services isCodeDeploy := localDefinition.CodeConfiguration != nil - if err := a.addToProject(ctx, srcDir, localDefinition.Name, isCodeDeploy); err != nil { + if err := a.addToProject(ctx, srcDir, localDefinition, isCodeDeploy); err != nil { return fmt.Errorf("failed to add agent to azure.yaml: %w", err) } - if srcDir == "." { - fmt.Printf(" %s %s\n", color.GreenString("+"), color.GreenString("agent.yaml")) - } else { - fmt.Printf(" %s %s\n", color.GreenString("+"), color.GreenString("%s/agent.yaml", srcDir)) - } - // Run post-init validations (advisory warnings only) validatePostInit(srcDir, localDefinition.CodeConfiguration) - fmt.Println("\nYou can customize environment variables and other settings in the agent.yaml.") + fmt.Println("\nYou can customize environment variables and other settings " + + "in the agent service entry in azure.yaml.") // Delegate the trailing Next: block to the shared nextstep // resolver — the same path used by the manifest-driven init @@ -766,41 +759,33 @@ func findDefaultModelIndex(modelNames []string) int32 { return 0 } -// writeDefinitionToSrcDir writes a ContainerAgent to a YAML file in the src directory and returns the path -func (a *InitFromCodeAction) writeDefinitionToSrcDir(definition *agent_yaml.ContainerAgent, srcDir string) (string, error) { - // Ensure the src directory exists +// writeAgentIgnoreToSrcDir generates a default .agentignore in srcDir if one +// does not already exist. The agent definition itself is written into the +// azure.yaml service entry (not an on-disk agent.yaml); .agentignore is still +// needed to scope code-deploy ZIP packaging. +func (a *InitFromCodeAction) writeAgentIgnoreToSrcDir(srcDir string) error { //nolint:gosec // scaffold directory should be readable/traversable for project tools if err := os.MkdirAll(srcDir, 0755); err != nil { - return "", fmt.Errorf("creating src directory: %w", err) + return fmt.Errorf("creating src directory: %w", err) } - // Create the definition file path - definitionPath := filepath.Join(srcDir, "agent.yaml") - - // Marshal the definition to YAML - content, err := yaml.Marshal(definition) - if err != nil { - return "", fmt.Errorf("marshaling definition to YAML: %w", err) - } - - // Write to the file - //nolint:gosec // generated manifest file should be readable by tooling and users - if err := os.WriteFile(definitionPath, content, 0644); err != nil { - return "", fmt.Errorf("writing definition to file: %w", err) - } - - // Generate .agentignore if it doesn't already exist agentIgnorePath := filepath.Join(srcDir, ".agentignore") if _, err := os.Stat(agentIgnorePath); os.IsNotExist(err) { if err := os.WriteFile(agentIgnorePath, []byte(project.DefaultAgentIgnoreContent()), osutil.PermissionFile); err != nil { - return "", fmt.Errorf("writing .agentignore: %w", err) + return fmt.Errorf("writing .agentignore: %w", err) } } - return definitionPath, nil + return nil } -func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, agentName string, isCodeDeploy bool) error { +func (a *InitFromCodeAction) addToProject( + ctx context.Context, + targetDir string, + definition *agent_yaml.ContainerAgent, + isCodeDeploy bool, +) error { + agentName := definition.Name // If targetDir is ".", resolve the actual relative path from the project root to cwd. // This ensures azure.yaml gets the correct "project:" value when init is run from a subdirectory. if targetDir == "." { @@ -837,40 +822,29 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, resourceDeployments := agentConfig.Deployments agentConfig.Deployments = nil - var agentConfigStruct *structpb.Struct - var err error - if agentConfigStruct, err = project.MarshalStruct(&agentConfig); err != nil { - return fmt.Errorf("failed to marshal agent config: %w", err) + // Embed the agent definition (formerly written to agent.yaml) as + // service-level properties on the azure.ai.agent entry, merged with the + // remaining agent config (container settings, startup command). + agentProps, err := project.AgentDefinitionToServiceProperties(*definition, &agentConfig) + if err != nil { + return err } language := "python" if !isCodeDeploy { language = "docker" - } else { - // Detect language from the on-disk definition. Skip manifest filenames: - // their fields are nested under template: and would not match here. - for _, name := range []string{"agent.yaml", "agent.yml"} { - langDetectPath := filepath.Join(a.projectConfig.Path, targetDir, name) - data, err := os.ReadFile(langDetectPath) //nolint:gosec // path from project config - if err != nil { - continue - } - var langDef agent_yaml.ContainerAgent - if err := yaml.Unmarshal(data, &langDef); err == nil && - langDef.CodeConfiguration != nil && - strings.HasPrefix(langDef.CodeConfiguration.Runtime, "dotnet_") { - language = "csharp" - } - break - } + } else if definition.CodeConfiguration != nil && + strings.HasPrefix(definition.CodeConfiguration.Runtime, "dotnet_") { + language = "csharp" } serviceConfig := &azdext.ServiceConfig{ - Name: strings.ReplaceAll(agentName, " ", ""), - RelativePath: targetDir, - Host: AiAgentHost, - Language: language, - Config: agentConfigStruct, + Name: strings.ReplaceAll(agentName, " ", ""), + RelativePath: targetDir, + Host: AiAgentHost, + Language: language, + Image: definition.Image, + AdditionalProperties: agentProps, } // For hosted container-based agents, enable remote build by default. It is diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_reuse.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_reuse.go index d528889d09c..ed493c0e673 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_reuse.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_reuse.go @@ -123,7 +123,7 @@ func runReuseDefinition( } isCodeDeploy := def.CodeConfiguration != nil - if err := action.addToProject(ctx, srcDir, def.Name, isCodeDeploy); err != nil { + if err := action.addToProject(ctx, srcDir, def, isCodeDeploy); err != nil { return fmt.Errorf("failed to add agent to azure.yaml: %w", err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go index 9384ea2623e..7dedb211c10 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go @@ -410,115 +410,38 @@ func TestExtractResourceGroup(t *testing.T) { } } -func TestWriteDefinitionToSrcDir(t *testing.T) { +func TestWriteAgentIgnoreToSrcDir(t *testing.T) { t.Parallel() - t.Run("writes agent.yaml to directory", func(t *testing.T) { + t.Run("writes .agentignore and not agent.yaml", func(t *testing.T) { t.Parallel() - dir := t.TempDir() - srcDir := filepath.Join(dir, "src") - - definition := &agent_yaml.ContainerAgent{ - AgentDefinition: agent_yaml.AgentDefinition{ - Name: "test-agent", - Kind: agent_yaml.AgentKindHosted, - }, - Protocols: []agent_yaml.ProtocolVersionRecord{ - {Protocol: "responses", Version: "1.0.0"}, - }, - EnvironmentVariables: &[]agent_yaml.EnvironmentVariable{ - {Name: "AZURE_AI_MODEL_DEPLOYMENT_NAME", Value: "${AZURE_AI_MODEL_DEPLOYMENT_NAME}"}, - }, - } + srcDir := filepath.Join(t.TempDir(), "src") action := &InitFromCodeAction{} - resultPath, err := action.writeDefinitionToSrcDir(definition, srcDir) - if err != nil { + if err := action.writeAgentIgnoreToSrcDir(srcDir); err != nil { t.Fatalf("unexpected error: %v", err) } - expectedPath := filepath.Join(srcDir, "agent.yaml") - if resultPath != expectedPath { - t.Errorf("path = %q, want %q", resultPath, expectedPath) - } - - //nolint:gosec // test fixture path is created within test temp directory - content, err := os.ReadFile(resultPath) - if err != nil { - t.Fatalf("failed to read written file: %v", err) - } - - contentStr := string(content) - // Verify key content is present in the YAML - if !containsAll(contentStr, "name: test-agent", "kind: hosted", "responses", "AZURE_AI_MODEL_DEPLOYMENT_NAME") { - t.Errorf("written content missing expected fields:\n%s", contentStr) + if _, err := os.Stat(filepath.Join(srcDir, ".agentignore")); err != nil { + t.Fatalf("expected .agentignore to exist: %v", err) } - // AZURE_OPENAI_ENDPOINT and FOUNDRY_PROJECT_ENDPOINT should NOT be written to agent.yaml. - // Hosted agents receive platform-provided FOUNDRY_* variables such as FOUNDRY_PROJECT_ENDPOINT instead. - if strings.Contains(contentStr, "AZURE_OPENAI_ENDPOINT") || strings.Contains(contentStr, "FOUNDRY_PROJECT_ENDPOINT") { - t.Errorf("agent.yaml should not contain AZURE_OPENAI_ENDPOINT or FOUNDRY_PROJECT_ENDPOINT:\n%s", contentStr) + // The agent definition now lives in azure.yaml; no agent.yaml on disk. + if _, err := os.Stat(filepath.Join(srcDir, "agent.yaml")); !os.IsNotExist(err) { + t.Fatalf("agent.yaml should not be written; stat err = %v", err) } }) t.Run("creates nested directories", func(t *testing.T) { t.Parallel() - dir := t.TempDir() - srcDir := filepath.Join(dir, "deep", "nested", "path") - - definition := &agent_yaml.ContainerAgent{ - AgentDefinition: agent_yaml.AgentDefinition{ - Name: "nested-agent", - Kind: agent_yaml.AgentKindHosted, - }, - } - - action := &InitFromCodeAction{} - _, err := action.writeDefinitionToSrcDir(definition, srcDir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if _, err := os.Stat(filepath.Join(srcDir, "agent.yaml")); err != nil { - t.Fatalf("expected file to exist: %v", err) - } - }) - - t.Run("overwrites existing file", func(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - existingFile := filepath.Join(dir, "agent.yaml") - //nolint:gosec // test fixture file permissions are intentional - if err := os.WriteFile(existingFile, []byte("old content"), 0644); err != nil { - t.Fatalf("write existing file: %v", err) - } - - definition := &agent_yaml.ContainerAgent{ - AgentDefinition: agent_yaml.AgentDefinition{ - Name: "new-agent", - Kind: agent_yaml.AgentKindHosted, - }, - } - + srcDir := filepath.Join(t.TempDir(), "deep", "nested", "path") action := &InitFromCodeAction{} - _, err := action.writeDefinitionToSrcDir(definition, dir) - if err != nil { + if err := action.writeAgentIgnoreToSrcDir(srcDir); err != nil { t.Fatalf("unexpected error: %v", err) } - - //nolint:gosec // test fixture path is created within test temp directory - content, err := os.ReadFile(existingFile) - if err != nil { - t.Fatalf("failed to read file: %v", err) - } - - if string(content) == "old content" { - t.Error("expected file to be overwritten, but old content remains") - } - if !containsAll(string(content), "name: new-agent") { - t.Errorf("written content missing expected fields:\n%s", string(content)) + if _, err := os.Stat(filepath.Join(srcDir, ".agentignore")); err != nil { + t.Fatalf("expected .agentignore to exist: %v", err) } }) } @@ -650,16 +573,6 @@ func stringSlicesEqual(a, b []string) bool { return true } -// containsAll checks that s contains all the given substrings. -func containsAll(s string, substrings ...string) bool { - for _, sub := range substrings { - if !strings.Contains(s, sub) { - return false - } - } - return true -} - func TestPromptProtocols_FlagValues(t *testing.T) { t.Parallel() 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 83f2372a949..796a8383c5a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -8,25 +8,17 @@ import ( "encoding/json" "errors" "fmt" - "io/fs" "log" - "net/url" "os" "strings" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" - "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/agents/optimize_api" - "azureaiagent/internal/pkg/azure" - "azureaiagent/internal/pkg/envkey" - "azureaiagent/internal/pkg/paths" "azureaiagent/internal/project" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/braydonk/yaml" - "google.golang.org/protobuf/types/known/structpb" ) // configureExtensionHost wires the service target and event handlers on the @@ -41,19 +33,6 @@ func configureExtensionHost(host *azdext.ExtensionHost) { WithServiceTarget(AiAgentHost, func() azdext.ServiceTargetProvider { return project.NewAgentServiceTargetProvider(azdClient) }). - // The Foundry resource hosts written by `azd ai agent init` are owned by - // this extension too, so `azd up`/`azd deploy` can walk them without a - // separate extension per host. They are no-ops today; the resources are - // created by Bicep at provision time. - WithServiceTarget(AiProjectHost, func() azdext.ServiceTargetProvider { - return project.NewResourceServiceTargetProvider(azdClient) - }). - WithServiceTarget(AiConnectionHost, func() azdext.ServiceTargetProvider { - return project.NewResourceServiceTargetProvider(azdClient) - }). - WithServiceTarget(AiToolboxHost, func() azdext.ServiceTargetProvider { - return project.NewResourceServiceTargetProvider(azdClient) - }). WithProvisioningProvider(project.FoundryProviderName, func() azdext.ProvisioningProvider { return project.NewFoundryProvisioningProvider(azdClient) }). @@ -104,28 +83,10 @@ func postprovisionHandler( azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs, ) error { - // Toolboxes live in sibling azure.ai.toolbox services; their connection - // enrichment still needs the project connections (azure.ai.connection - // services) and the agent tool connections. - toolboxes, err := collectToolboxes(args.Project.Services) - if err != nil { - return fmt.Errorf("failed to collect toolboxes: %w", err) - } - - if len(toolboxes) > 0 { - connections, err := collectConnections(args.Project.Services) - if err != nil { - return fmt.Errorf("failed to collect connections: %w", err) - } - toolConnections, err := collectAgentToolConnections(args.Project.Services) - if err != nil { - return fmt.Errorf("failed to collect tool connections: %w", err) - } - - if err := provisionToolboxes(ctx, azdClient, toolboxes, connections, toolConnections); err != nil { - return fmt.Errorf("failed to provision toolboxes: %w", err) - } - } + // Toolboxes are reconciled at deploy time by the azure.ai.toolbox service + // target (the azure.ai.toolboxes extension), not at provision. The agent + // service's uses: edges order each toolbox before the agent that consumes + // it, so the toolbox MCP endpoints are published before the agent deploys. hasAgent := false for _, svc := range args.Project.Services { @@ -231,23 +192,12 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az return nil } -// isHostedAgentService checks if a service is a hosted (container) agent by reading -// the agent.yaml kind from the service directory. +// isHostedAgentService checks if a service is a hosted (container) agent by +// resolving its agent definition from the service entry (the unified inline +// shape, or a legacy agent.yaml on disk). func isHostedAgentService(svc *azdext.ServiceConfig, proj *azdext.ProjectConfig) bool { - agentYamlPath, err := paths.JoinAllowRoot(proj.Path, svc.RelativePath, "agent.yaml") - if err != nil { - return false - } - data, err := os.ReadFile(agentYamlPath) //nolint:gosec // path from azd project config - if err != nil { - return false - } - var generic map[string]any - if err := yaml.Unmarshal(data, &generic); err != nil { - return false - } - kind, ok := generic["kind"].(string) - return ok && kind == string(agent_yaml.AgentKindHosted) + _, isHosted, _, err := project.LoadAgentDefinition(svc, proj.Path) + return err == nil && isHosted } func postdeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { @@ -432,9 +382,8 @@ func envUpdate( connections []project.Connection, ) error { - var foundryAgentConfig *project.ServiceTargetAgentConfig - - if err := project.UnmarshalStruct(svc.Config, &foundryAgentConfig); err != nil { + foundryAgentConfig, err := project.LoadServiceTargetAgentConfig(svc) + if err != nil { return fmt.Errorf("failed to parse foundry agent config: %w", err) } @@ -492,40 +441,36 @@ func envUpdate( // agents inline in azure.yaml, so a missing file short-circuits cleanly here. // Service-targets that truly need agent.yaml still surface the error where they // read its contents. -func kindEnvUpdate(ctx context.Context, azdClient *azdext.AzdClient, project *azdext.ProjectConfig, svc *azdext.ServiceConfig, envName string) error { - agentYamlPath, err := paths.JoinAllowRoot(project.Path, svc.RelativePath, "agent.yaml") - if err != nil { - return fmt.Errorf("invalid service path: %w", err) - } - - //nolint:gosec // agentYamlPath is resolved from project/service paths in current workspace - data, err := os.ReadFile(agentYamlPath) +func kindEnvUpdate( + ctx context.Context, + azdClient *azdext.AzdClient, + azdProject *azdext.ProjectConfig, + svc *azdext.ServiceConfig, + envName string, +) error { + // The agent definition is carried inline on the service entry (unified shape) + // or, for older projects, in a legacy agent.yaml on disk. A missing or + // unreadable definition is tolerated here: the bicepless inline path lets + // users declare prompt agents that carry no hosted definition, and service + // targets that truly need the definition surface the error where they read it. + _, isHosted, source, err := project.LoadAgentDefinition(svc, azdProject.Path) if err != nil { - if errors.Is(err, fs.ErrNotExist) { - // Bicepless inline-agents path: no on-disk agent.yaml required. - log.Printf("[debug] kindEnvUpdate: no agent.yaml at %s; skipping (inline-agents path)", agentYamlPath) + // Tolerate only a missing definition: the bicepless inline path lets users + // declare prompt agents that carry no hosted definition. Validation and + // path-traversal errors still propagate so a malformed or out-of-tree + // definition fails fast here. + if localErr, ok := errors.AsType[*azdext.LocalError](err); ok && + localErr.Code == exterrors.CodeAgentDefinitionNotFound { + log.Printf("[debug] kindEnvUpdate: no agent definition for %s; skipping (inline-agents path)", svc.Name) return nil } - return fmt.Errorf("failed to read YAML file: %w", err) - } - - err = agent_yaml.ValidateAgentDefinition(data) - if err != nil { - return fmt.Errorf("agent.yaml is not valid: %w", err) - } - - var genericTemplate map[string]any - if err := yaml.Unmarshal(data, &genericTemplate); err != nil { - return fmt.Errorf("YAML content is not valid: %w", err) + return err } - - kind, ok := genericTemplate["kind"].(string) - if !ok { - return fmt.Errorf("kind field is not a valid string") + if source.IsLegacy() { + project.WarnLegacyAgentShape(source) } - switch kind { - case string(agent_yaml.AgentKindHosted): + if isHosted { if err := setEnvVar(ctx, azdClient, envName, "ENABLE_HOSTED_AGENTS", "true"); err != nil { return err } @@ -673,51 +618,33 @@ func setEnvVar(ctx context.Context, azdClient *azdext.AzdClient, envName string, } func populateContainerSettings(ctx context.Context, azdClient *azdext.AzdClient, svc *azdext.ServiceConfig) error { - var foundryAgentConfig *project.ServiceTargetAgentConfig - if err := project.UnmarshalStruct(svc.Config, &foundryAgentConfig); err != nil { + foundryAgentConfig, err := project.LoadServiceTargetAgentConfig(svc) + if err != nil { return fmt.Errorf("failed to parse foundry agent config: %w", err) } - // Initialize result with existing values - result := &project.ContainerSettings{} - - // Check and populate base object - containerSettings := foundryAgentConfig.Container - if containerSettings == nil { - containerSettings = &project.ContainerSettings{} - } - - // Check and populate Resources - if containerSettings.Resources == nil { - result.Resources = &project.ResourceSettings{} - } else { - result.Resources = &project.ResourceSettings{ - Memory: containerSettings.Resources.Memory, - Cpu: containerSettings.Resources.Cpu, - } + // Resolve the container resources, applying defaults when unset. + result := &project.ResourceSettings{} + if foundryAgentConfig.Container != nil && foundryAgentConfig.Container.Resources != nil { + result.Memory = foundryAgentConfig.Container.Resources.Memory + result.Cpu = foundryAgentConfig.Container.Resources.Cpu } // Set default values if zero or empty - if result.Resources.Memory == "" { - result.Resources.Memory = project.DefaultMemory + if result.Memory == "" { + result.Memory = project.DefaultMemory } - if result.Resources.Cpu == "" { - result.Resources.Cpu = project.DefaultCpu + if result.Cpu == "" { + result.Cpu = project.DefaultCpu } - // Update the container settings in the existing config - foundryAgentConfig.Container = result - - // Marshal the complete updated agent config back to the service config - var agentConfigStruct *structpb.Struct - var err error - if agentConfigStruct, err = project.MarshalStruct(foundryAgentConfig); err != nil { - return fmt.Errorf("failed to marshal agent config: %w", err) + // Persist the resolved container settings back onto the service's inline + // properties, preserving the agent definition and other config keys. + if err := project.SetAgentContainerSettings(svc, &project.ContainerSettings{Resources: result}); err != nil { + return fmt.Errorf("failed to update agent container settings: %w", err) } - svc.Config = agentConfigStruct - // Need to add the service config back to the project for use further down the pipeline req := &azdext.AddServiceRequest{Service: svc} @@ -728,174 +655,6 @@ func populateContainerSettings(ctx context.Context, azdClient *azdext.AzdClient, return nil } -// provisionToolboxes creates or updates Foundry Toolsets for each toolbox -// sourced from the sibling azure.ai.toolbox services. Called during -// post-provision after the project endpoint has been created by Bicep. The -// connections and toolConnections are used to resolve connection references -// declared on the toolboxes. -func provisionToolboxes( - ctx context.Context, - azdClient *azdext.AzdClient, - toolboxes []project.Toolbox, - connections []project.Connection, - toolConnections []project.ToolConnection, -) error { - if len(toolboxes) == 0 { - return nil - } - - // Build connection lookup for enriching tool entries with server_url/server_label - connByName := toolboxConnectionsByName(&project.ServiceTargetAgentConfig{ - Connections: connections, - ToolConnections: toolConnections, - }) - - currentEnv, err := azdClient.Environment().GetCurrent( - ctx, &azdext.EmptyRequest{}, - ) - if err != nil { - return fmt.Errorf("failed to get current environment: %w", err) - } - - envValue, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: currentEnv.Environment.Name, - Key: "FOUNDRY_PROJECT_ENDPOINT", - }) - if err != nil || envValue.Value == "" { - return exterrors.Dependency( - exterrors.CodeMissingAiProjectEndpoint, - "FOUNDRY_PROJECT_ENDPOINT is required for toolbox provisioning", - "run 'azd provision' to create the AI project first", - ) - } - projectEndpoint := envValue.Value - - envValue, err = azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: currentEnv.Environment.Name, - Key: "AZURE_TENANT_ID", - }) - if err != nil || envValue.Value == "" { - return exterrors.Dependency( - exterrors.CodeMissingAzureTenantId, - "AZURE_TENANT_ID is required for toolbox provisioning", - "run 'azd auth login' to authenticate", - ) - } - tenantId := envValue.Value - - cred, err := azidentity.NewAzureDeveloperCLICredential( - &azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: tenantId, - AdditionallyAllowedTenants: []string{"*"}, - }, - ) - if err != nil { - return exterrors.Auth( - exterrors.CodeCredentialCreationFailed, - fmt.Sprintf("failed to create credential: %s", err), - "run 'azd auth login' to authenticate", - ) - } - - toolboxClient := azure.NewFoundryToolboxClient( - projectEndpoint, cred, - ) - - // Build azd env lookup for resolving ${VAR} references in tool entries - azdEnv, err := getAllEnvVars(ctx, azdClient, currentEnv.Environment.Name) - if err != nil { - return fmt.Errorf("failed to load environment variables: %w", err) - } - - // Build connection ID lookup from bicep outputs (name → ARM resource ID) - connIDMap, err := parseConnectionIDs(azdEnv["AI_PROJECT_CONNECTION_IDS_JSON"]) - if err != nil { - return fmt.Errorf("loading connection IDs: %w", err) - } - - for _, toolbox := range toolboxes { - fmt.Fprintf( - os.Stderr, "Provisioning toolbox: %s\n", toolbox.Name, - ) - - // Resolve ${VAR} references in tool map values before sending to API - resolveToolboxEnvVars(&toolbox, azdEnv) - - // Fill in server_url/server_label from connection data - enrichToolboxFromConnections(&toolbox, connByName) - - // Replace project_connection_id friendly names with ARM resource IDs - resolveToolboxConnectionIDs(&toolbox, connIDMap) - - version, err := createToolboxVersion( - ctx, toolboxClient, toolbox, - ) - if err != nil { - return err - } - - if err := registerToolboxEnvVars( - ctx, azdClient, - currentEnv.Environment.Name, - projectEndpoint, toolbox.Name, version, - ); err != nil { - return err - } - - fmt.Fprintf( - os.Stderr, "Toolbox '%s' provisioned\n", toolbox.Name, - ) - } - - return nil -} - -// createToolboxVersion creates a new version of a toolbox. -// If the toolbox does not exist, it will be created automatically. -// Returns the version identifier of the newly created version. -func createToolboxVersion( - ctx context.Context, - client *azure.FoundryToolboxClient, - toolbox project.Toolbox, -) (string, error) { - req := &azure.CreateToolboxVersionRequest{ - Description: toolbox.Description, - Tools: toolbox.Tools, - } - - result, err := client.CreateToolboxVersion(ctx, toolbox.Name, req) - if err != nil { - return "", exterrors.Internal( - exterrors.CodeCreateToolboxVersionFailed, - fmt.Sprintf("failed to create toolbox version '%s': %s", toolbox.Name, err), - ) - } - - return result.Version, nil -} - -// registerToolboxEnvVars sets TOOLBOX_{NAME}_MCP_ENDPOINT with the versioned URL. -func registerToolboxEnvVars( - ctx context.Context, - azdClient *azdext.AzdClient, - envName string, - projectEndpoint string, - toolboxName string, - toolboxVersion string, -) error { - envKey := envkey.ToolboxMCPEndpoint(toolboxName) - - endpoint := strings.TrimRight(projectEndpoint, "/") - mcpEndpoint := fmt.Sprintf( - "%s/toolboxes/%s/versions/%s/mcp?api-version=v1", - endpoint, url.PathEscape(toolboxName), url.PathEscape(toolboxVersion), - ) - - return setEnvVar( - ctx, azdClient, envName, envKey, mcpEndpoint, - ) -} - // resolveToolboxEnvVars resolves ${VAR} references in toolbox name, description, // and all tool map values using the provided azd environment variables. func resolveToolboxEnvVars(toolbox *project.Toolbox, azdEnv map[string]string) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go index 312d5cdf7b6..5939600bf91 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go @@ -22,6 +22,8 @@ import ( "azureaiagent/internal/pkg/agents/opt_eval" "azureaiagent/internal/pkg/agents/optimize_api" + "azureaiagent/internal/pkg/paths" + projectpkg "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/fatih/color" @@ -117,7 +119,10 @@ func (a *OptimizeApplyAction) apply( return err } - serviceDir := filepath.Join(project.Path, svc.RelativePath) + serviceDir, err := paths.JoinAllowRoot(project.Path, svc.RelativePath) + if err != nil { + return fmt.Errorf("invalid service path for %s: %w", svc.Name, err) + } candidateDir := filepath.Join(serviceDir, agentConfigsDir, a.flags.candidate) _, _ = bold.Fprintf(out, "Applying optimization candidate %s...\n\n", a.flags.candidate) @@ -160,15 +165,33 @@ func (a *OptimizeApplyAction) apply( } fmt.Fprintf(out, " → %s\n", filepath.Join(candidateDir, opt_eval.MetadataFile)) - // Step 3: Write OPTIMIZATION_LOCAL_DIR and OPTIMIZATION_CANDIDATE_ID into agent.yaml - // so the deploy pipeline knows which local optimization config to use. - agentYamlPath := filepath.Join(serviceDir, "agent.yaml") - fmt.Fprintf(out, " Updating %s...\n", agentYamlPath) - if err := upsertAgentYamlEnvVar(agentYamlPath, "OPTIMIZATION_LOCAL_DIR", agentConfigsDir); err != nil { - return fmt.Errorf("failed to update agent.yaml: %w", err) - } - if err := upsertAgentYamlEnvVar(agentYamlPath, "OPTIMIZATION_CANDIDATE_ID", a.flags.candidate); err != nil { - return fmt.Errorf("failed to update agent.yaml: %w", err) + // Step 3: Persist OPTIMIZATION_LOCAL_DIR and OPTIMIZATION_CANDIDATE_ID onto the + // agent definition so the deploy pipeline knows which local optimization + // config to use. New projects carry the definition inline in azure.yaml; + // older projects still keep it in an on-disk agent.yaml. + envUpdates := map[string]string{ + "OPTIMIZATION_LOCAL_DIR": agentConfigsDir, + "OPTIMIZATION_CANDIDATE_ID": a.flags.candidate, + } + if _, _, found, _, err := projectpkg.AgentDefinitionFromService(svc); err != nil { + return fmt.Errorf("failed to read agent definition: %w", err) + } else if found { + fmt.Fprintf(out, " Updating agent definition in azure.yaml...\n") + if err := projectpkg.UpsertAgentEnvVars(svc, envUpdates); err != nil { + return fmt.Errorf("failed to update agent definition: %w", err) + } + if _, err := azdClient.Project().AddService(ctx, &azdext.AddServiceRequest{Service: svc}); err != nil { + return fmt.Errorf("failed to persist agent definition: %w", err) + } + } else { + agentYamlPath := filepath.Join(serviceDir, "agent.yaml") + fmt.Fprintf(out, " Updating %s...\n", agentYamlPath) + if err := upsertAgentYamlEnvVar(agentYamlPath, "OPTIMIZATION_LOCAL_DIR", agentConfigsDir); err != nil { + return fmt.Errorf("failed to update agent.yaml: %w", err) + } + if err := upsertAgentYamlEnvVar(agentYamlPath, "OPTIMIZATION_CANDIDATE_ID", a.flags.candidate); err != nil { + return fmt.Errorf("failed to update agent.yaml: %w", err) + } } // Step 4: Store candidate ID in the azd environment for tracking. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go index ecc5016a227..57030b69888 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -6,6 +6,7 @@ package cmd import ( "context" "fmt" + "os" "sort" "strings" @@ -85,6 +86,10 @@ func emitResourceServices( conn := connections[i] connName := sanitizeServiceName(conn.Name) if connName == "" { + fmt.Fprintf(os.Stderr, + "warning: connection %q has no characters usable as an azure.yaml service key; "+ + "skipping it. Rename the connection so it is written to azure.yaml.\n", + conn.Name) continue } if err := reserveServiceName(usedNames, connName, fmt.Sprintf("connection %q", conn.Name)); err != nil { @@ -104,6 +109,10 @@ func emitResourceServices( toolbox := toolboxes[i] toolboxName := sanitizeServiceName(toolbox.Name) if toolboxName == "" { + fmt.Fprintf(os.Stderr, + "warning: toolbox %q has no characters usable as an azure.yaml service key; "+ + "skipping it. Rename the toolbox so it is written to azure.yaml.\n", + toolbox.Name) continue } if err := reserveServiceName(usedNames, toolboxName, fmt.Sprintf("toolbox %q", toolbox.Name)); err != nil { @@ -130,9 +139,10 @@ func emitResourceServices( } // addResourceService adds a single Foundry resource service to azure.yaml with -// its schema under config: and optionally wires its uses: list. The service is -// added with an empty language so azd resolves a no-op framework; the owning -// extension's service-target provider handles its (currently no-op) lifecycle. +// its keys composed at the service level (inline, via AdditionalProperties, the +// same shape the agent service uses) and optionally wires its uses: list. The +// service is added with an empty language so azd resolves a no-op framework; the +// owning extension's service-target provider handles its lifecycle. func addResourceService( ctx context.Context, azdClient *azdext.AzdClient, @@ -142,9 +152,9 @@ func addResourceService( uses []string, ) error { svc := &azdext.ServiceConfig{ - Name: name, - Host: host, - Config: cfg, + Name: name, + Host: host, + AdditionalProperties: cfg, } if _, err := azdClient.Project().AddService(ctx, &azdext.AddServiceRequest{Service: svc}); err != nil { @@ -185,9 +195,12 @@ func setServiceUses(ctx context.Context, azdClient *azdext.AzdClient, serviceNam return nil } -// sanitizeServiceName converts a resource name into a valid azure.yaml service -// key by trimming and removing spaces, matching how the agent service name is -// derived from the agent name. +// sanitizeServiceName converts a resource name into an azure.yaml service key by +// trimming surrounding whitespace and removing interior spaces, matching how the +// agent service name is derived from the agent name. Only spaces are stripped, so +// the name is expected to otherwise consist of characters valid in a YAML map key +// (letters, digits, '-', '_', '.'); Foundry resource names already meet this. A +// name that reduces to an empty string is skipped by the caller with a warning. func sanitizeServiceName(name string) string { return strings.ReplaceAll(strings.TrimSpace(name), " ", "") } @@ -220,11 +233,12 @@ func reserveServiceName(used map[string]string, name, source string) error { func collectProjectDeployments(services map[string]*azdext.ServiceConfig) ([]project.Deployment, error) { var out []project.Deployment for _, svc := range sortedServices(services) { - if svc.Host != AiProjectHost || svc.Config == nil { + props := project.ServiceConfigProps(svc) + if svc.Host != AiProjectHost || props == nil { continue } var cfg *project.ServiceTargetAgentConfig - if err := project.UnmarshalStruct(svc.Config, &cfg); err != nil { + if err := project.UnmarshalStruct(props, &cfg); err != nil { return nil, fmt.Errorf("parsing project service %q config: %w", svc.Name, err) } if cfg != nil { @@ -251,11 +265,12 @@ func collectProjectDeployments(services map[string]*azdext.ServiceConfig) ([]pro func collectConnections(services map[string]*azdext.ServiceConfig) ([]project.Connection, error) { var out []project.Connection for _, svc := range sortedServices(services) { - if svc.Host != AiConnectionHost || svc.Config == nil { + props := project.ServiceConfigProps(svc) + if svc.Host != AiConnectionHost || props == nil { continue } var conn *project.Connection - if err := project.UnmarshalStruct(svc.Config, &conn); err != nil { + if err := project.UnmarshalStruct(props, &conn); err != nil { return nil, fmt.Errorf("parsing connection service %q config: %w", svc.Name, err) } if conn != nil { @@ -282,11 +297,12 @@ func collectConnections(services map[string]*azdext.ServiceConfig) ([]project.Co func collectToolboxes(services map[string]*azdext.ServiceConfig) ([]project.Toolbox, error) { var out []project.Toolbox for _, svc := range sortedServices(services) { - if svc.Host != AiToolboxHost || svc.Config == nil { + props := project.ServiceConfigProps(svc) + if svc.Host != AiToolboxHost || props == nil { continue } var toolbox *project.Toolbox - if err := project.UnmarshalStruct(svc.Config, &toolbox); err != nil { + if err := project.UnmarshalStruct(props, &toolbox); err != nil { return nil, fmt.Errorf("parsing toolbox service %q config: %w", svc.Name, err) } if toolbox != nil { @@ -330,11 +346,14 @@ func collectAgentToolConnections(services map[string]*azdext.ServiceConfig) ([]p func collectLegacyAgentConfigs(services map[string]*azdext.ServiceConfig) ([]*project.ServiceTargetAgentConfig, error) { var out []*project.ServiceTargetAgentConfig for _, svc := range sortedServices(services) { - if svc.Host != AiAgentHost || svc.Config == nil { + if svc.Host != AiAgentHost { continue } - var cfg *project.ServiceTargetAgentConfig - if err := project.UnmarshalStruct(svc.Config, &cfg); err != nil { + if project.ServiceConfigProps(svc) == nil { + continue + } + cfg, err := project.LoadServiceTargetAgentConfig(svc) + if err != nil { return nil, fmt.Errorf("parsing agent service %q config: %w", svc.Name, err) } if cfg != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go index 3459f6af91a..9ec235a2ca8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -367,3 +367,46 @@ func TestEmitResourceServices_WiresSiblingsToProject(t *testing.T) { assert.Equal(t, []string{aiProjectServiceName}, server.uses["myconn"]) assert.Equal(t, []string{aiProjectServiceName, "myconn"}, server.uses["myagent"]) } + +// TestEmitResourceServices_WritesServiceLevelProps verifies resource services are +// written with their keys composed at the service level (inline via +// AdditionalProperties, matching the agent service shape and the config:false +// host schema conditionals) rather than nested under config:, and that the +// collectors read that service-level shape back. +func TestEmitResourceServices_WritesServiceLevelProps(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + + deployments := []project.Deployment{{ + Name: "gpt-4.1-mini", + Model: project.DeploymentModel{Format: "OpenAI", Name: "gpt-4.1-mini", Version: "2025-04-14"}, + Sku: project.DeploymentSku{Name: "GlobalStandard", Capacity: 10}, + }} + conns := []project.Connection{{Name: "myconn", Category: "ApiKey", Target: "https://example", AuthType: "ApiKey"}} + require.NoError(t, emitResourceServices(t.Context(), client, "myagent", deployments, conns, nil)) + + server.mu.Lock() + defer server.mu.Unlock() + + services := map[string]*azdext.ServiceConfig{} + for _, svc := range server.added { + // Resource keys must travel at the service level, not under config:. + assert.Nil(t, svc.Config, "service %q must not nest keys under config:", svc.Name) + assert.NotNil(t, svc.AdditionalProperties, "service %q must carry service-level keys", svc.Name) + services[svc.Name] = svc + } + + // The collectors read the service-level shape back through ServiceConfigProps. + gotDeployments, err := collectProjectDeployments(services) + require.NoError(t, err) + require.Len(t, gotDeployments, 1) + assert.Equal(t, "gpt-4.1-mini", gotDeployments[0].Name) + + gotConns, err := collectConnections(services) + require.NoError(t, err) + require.Len(t, gotConns, 1) + assert.Equal(t, "myconn", gotConns[0].Name) + assert.Equal(t, "ApiKey", gotConns[0].Category) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go index 5deadaec9cb..2b4bf13bd80 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -29,7 +29,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" - "go.yaml.in/yaml/v3" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -194,7 +193,7 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { // the Foundry data plane). Agent definition env vars do not override // values already present in the process environment. endpoint, _ := resolveAgentEndpoint(ctx, "", "") - defEnv, defErr := resolveAgentDefinitionEnvVars(ctx, projectDir, azdEnvVars, endpoint) + defEnv, defErr := resolveAgentDefinitionEnvVars(ctx, runCtx.Definition, azdEnvVars, endpoint) if defErr != nil { fmt.Fprintf(os.Stderr, "Warning: %s\n", defErr) } @@ -456,38 +455,22 @@ func shouldWarnLoadAzdEnvironmentFailure(err error) bool { return !strings.Contains(strings.ToLower(msg), "default environment not found") } -// resolveAgentDefinitionEnvVars loads agent.yaml from projectDir, extracts +// resolveAgentDefinitionEnvVars takes a resolved agent definition, extracts its // environment_variables, and resolves all value types: // - Hardcoded values are used as-is // - ${VAR} references are resolved using azdEnvVars via envsubst // - ${{connections..credentials.}} are resolved via Foundry API // -// Returns nil if no agent.yaml is found or it has no environment_variables. +// Returns nil when the definition is nil or has no environment_variables. // Errors during connection resolution are returned so the caller can decide // whether to warn or fail. func resolveAgentDefinitionEnvVars( ctx context.Context, - projectDir string, + agentDef *agent_yaml.ContainerAgent, azdEnvVars map[string]string, endpoint string, ) ([]string, error) { - // Find agent.yaml in projectDir - agentYamlPath := findAgentYaml(projectDir) - if agentYamlPath == "" { - return nil, nil - } - - data, err := os.ReadFile(agentYamlPath) //nolint:gosec // G304: path from findAgentYaml which checks known filenames in projectDir - if err != nil { - return nil, fmt.Errorf("could not read agent definition %s: %w", agentYamlPath, err) - } - - var agentDef agent_yaml.ContainerAgent - if err := yaml.Unmarshal(data, &agentDef); err != nil { - return nil, fmt.Errorf("could not parse agent definition %s: %w", agentYamlPath, err) - } - - if agentDef.EnvironmentVariables == nil || len(*agentDef.EnvironmentVariables) == 0 { + if agentDef == nil || agentDef.EnvironmentVariables == nil || len(*agentDef.EnvironmentVariables) == 0 { return nil, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go index 3a9dbc3888d..868607babb0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go @@ -20,7 +20,10 @@ import ( "testing" "time" + "azureaiagent/internal/pkg/agents/agent_yaml" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + goyaml "go.yaml.in/yaml/v3" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -872,20 +875,25 @@ func TestFindAgentYaml(t *testing.T) { func TestResolveAgentDefinitionEnvVars(t *testing.T) { t.Parallel() + parse := func(t *testing.T, y string) *agent_yaml.ContainerAgent { + t.Helper() + var def agent_yaml.ContainerAgent + if err := goyaml.Unmarshal([]byte(y), &def); err != nil { + t.Fatalf("failed to parse agent definition: %v", err) + } + return &def + } + t.Run("hardcoded values", func(t *testing.T) { - dir := t.TempDir() - yaml := `name: test-agent + def := parse(t, `name: test-agent environment_variables: - name: TOOLBOX_NAME value: my-toolbox - name: LOG_LEVEL value: debug -` - if err := os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(yaml), 0600); err != nil { - t.Fatal(err) - } +`) - result, err := resolveAgentDefinitionEnvVars(t.Context(), dir, nil, "") + result, err := resolveAgentDefinitionEnvVars(t.Context(), def, nil, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -898,22 +906,18 @@ environment_variables: }) t.Run("resolves ${VAR} references", func(t *testing.T) { - dir := t.TempDir() - yaml := `name: test-agent + def := parse(t, `name: test-agent environment_variables: - name: MY_ENDPOINT value: ${FOUNDRY_PROJECT_ENDPOINT}/agents - name: PLAIN value: hardcoded -` - if err := os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(yaml), 0600); err != nil { - t.Fatal(err) - } +`) azdEnv := map[string]string{ "FOUNDRY_PROJECT_ENDPOINT": "https://example.azure.com", } - result, err := resolveAgentDefinitionEnvVars(t.Context(), dir, azdEnv, "") + result, err := resolveAgentDefinitionEnvVars(t.Context(), def, azdEnv, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -926,19 +930,15 @@ environment_variables: }) t.Run("skips connection refs without endpoint", func(t *testing.T) { - dir := t.TempDir() - yaml := `name: test-agent + def := parse(t, `name: test-agent environment_variables: - name: API_KEY value: "${{connections.my-conn.credentials.key}}" - name: STATIC value: hello -` - if err := os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(yaml), 0600); err != nil { - t.Fatal(err) - } +`) - result, err := resolveAgentDefinitionEnvVars(t.Context(), dir, nil, "") + result, err := resolveAgentDefinitionEnvVars(t.Context(), def, nil, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -953,9 +953,8 @@ environment_variables: } }) - t.Run("returns nil for missing agent.yaml", func(t *testing.T) { - dir := t.TempDir() - result, err := resolveAgentDefinitionEnvVars(t.Context(), dir, nil, "") + t.Run("returns nil for nil definition", func(t *testing.T) { + result, err := resolveAgentDefinitionEnvVars(t.Context(), nil, nil, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -965,14 +964,9 @@ environment_variables: }) t.Run("returns nil for empty environment_variables", func(t *testing.T) { - dir := t.TempDir() - yaml := `name: test-agent -` - if err := os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(yaml), 0600); err != nil { - t.Fatal(err) - } + def := parse(t, "name: test-agent\n") - result, err := resolveAgentDefinitionEnvVars(t.Context(), dir, nil, "") + result, err := resolveAgentDefinitionEnvVars(t.Context(), def, nil, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -982,17 +976,13 @@ environment_variables: }) t.Run("unresolved ${VAR} becomes empty", func(t *testing.T) { - dir := t.TempDir() - yaml := `name: test-agent + def := parse(t, `name: test-agent environment_variables: - name: MISSING_REF value: ${DOES_NOT_EXIST} -` - if err := os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(yaml), 0600); err != nil { - t.Fatal(err) - } +`) - result, err := resolveAgentDefinitionEnvVars(t.Context(), dir, map[string]string{}, "") + result, err := resolveAgentDefinitionEnvVars(t.Context(), def, map[string]string{}, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1000,23 +990,4 @@ environment_variables: t.Errorf("expected MISSING_REF= (empty), got %v", result) } }) - - t.Run("returns error for invalid YAML", func(t *testing.T) { - dir := t.TempDir() - invalid := `name: [unclosed bracket` - if err := os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(invalid), 0600); err != nil { - t.Fatal(err) - } - - result, err := resolveAgentDefinitionEnvVars(t.Context(), dir, nil, "") - if err == nil { - t.Fatal("expected error for invalid YAML, got nil") - } - if !strings.Contains(err.Error(), "could not parse agent definition") { - t.Errorf("unexpected error message: %v", err) - } - if result != nil { - t.Errorf("expected nil result, got %v", result) - } - }) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/update.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/update.go index 95f3c4a5007..431c676a459 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/update.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/update.go @@ -12,13 +12,12 @@ import ( "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" - "azureaiagent/internal/pkg/paths" + "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" "github.com/spf13/cobra" - goyaml "go.yaml.in/yaml/v3" ) func newEndpointCommand(extCtx *azdext.ExtensionContext) *cobra.Command { @@ -47,8 +46,9 @@ func newEndpointUpdateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Short: "Update an agent's endpoint and card configuration without deploying a new version.", Long: `Update an agent's endpoint and card configuration without deploying a new version. -This command reads the agent_endpoint and agent_card sections from agent.yaml and -patches the existing agent with those values. No new agent version is created. +This command reads the agentEndpoint and agentCard fields from the azure.ai.agent +service in azure.yaml, or agent_endpoint and agent_card from a legacy agent.yaml, +and patches the existing agent with those values. No new agent version is created. The agent must already exist (i.e., it must have been previously deployed).`, Example: ` # Update endpoint/card for the default agent service @@ -91,25 +91,21 @@ func runEndpointUpdate( return err } - // Read and parse agent.yaml. - agentYamlPath, err := paths.JoinAllowRoot(proj.Path, svc.RelativePath, "agent.yaml") + // Resolve the agent definition (inline on the service entry, or a legacy + // agent.yaml on disk). + agentDef, _, source, err := project.LoadAgentDefinition(svc, proj.Path) if err != nil { - return fmt.Errorf("invalid agent.yaml path: %w", err) + return fmt.Errorf("failed to resolve agent definition: %w", err) } - data, err := os.ReadFile(agentYamlPath) //nolint:gosec // path validated by JoinAllowRoot - if err != nil { - return fmt.Errorf("failed to read agent.yaml: %w", err) - } - - var agentDef agent_yaml.ContainerAgent - if err := goyaml.Unmarshal(data, &agentDef); err != nil { - return fmt.Errorf("failed to parse agent.yaml: %w", err) + if source.IsLegacy() { + project.WarnLegacyAgentShape(source) } // Validate that endpoint or card is defined. if agentDef.AgentEndpoint == nil && agentDef.AgentCard == nil { return fmt.Errorf( - "agent.yaml for service %q does not define agent_endpoint or agent_card — nothing to update", + "agent service %q does not define agentEndpoint or agentCard in azure.yaml "+ + "(or agent_endpoint or agent_card in legacy agent.yaml) — nothing to update", svc.Name, ) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index c7afa23d25b..762fa8db911 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -13,6 +13,11 @@ const ( // These are usually paired with [Validation] when user input, manifests, // or configuration values fail validation. const ( + // CodeInvalidAgentManifest is retained while azd still reads the deprecated + // on-disk agent manifest (agent.yaml/agent.manifest.yaml) during the + // migration window. Rename or retire it once the on-disk manifest path is + // removed and the agent definition is read only from azure.yaml (see the + // unify-azure-yaml design, §2.9). CodeInvalidAgentManifest = "invalid_agent_manifest" CodeInvalidManifestPointer = "invalid_manifest_pointer" CodeInvalidProjectResourceId = "invalid_project_resource_id" diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go new file mode 100644 index 00000000000..2b011ee05a6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -0,0 +1,496 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "fmt" + "log" + "os" + "sync" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/paths" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/braydonk/yaml" + "google.golang.org/protobuf/types/known/structpb" +) + +// AgentDefinitionSource identifies where a loaded agent definition came from. +type AgentDefinitionSource int + +const ( + // AgentDefinitionSourceInline means the definition was read from the agent + // service entry's service-level (inline) properties — the unified shape. + AgentDefinitionSourceInline AgentDefinitionSource = iota + // AgentDefinitionSourceLegacyConfig means the definition was read from the + // deprecated config-nested shape (a populated `config:` on the service). + AgentDefinitionSourceLegacyConfig + // AgentDefinitionSourceDisk means the definition was read from a legacy + // agent.yaml/agent.yml file on disk (the deprecated file-based shape). + AgentDefinitionSourceDisk +) + +// IsLegacy reports whether the source is one of the deprecated shapes (a +// config-nested entry or an on-disk agent.yaml) that callers should warn about. +func (s AgentDefinitionSource) IsLegacy() bool { + return s == AgentDefinitionSourceLegacyConfig || s == AgentDefinitionSourceDisk +} + +// MigrationGuideURL points at guidance for migrating older Foundry agent +// projects onto the unified azure.yaml shape. +const MigrationGuideURL = "https://github.com/Azure/azure-dev/tree/main/cli/azd/extensions/" + + "azure.ai.agents#migrating-legacy-agent-configuration" + +var legacyAgentShapeWarnOnce sync.Once + +// WarnLegacyAgentShape prints a one-time deprecation warning when an agent +// definition is read from a deprecated shape — an on-disk agent.yaml or the +// config-nested azure.ai.agent service entry — rather than from the unified +// service-level properties. azd keeps reading the old shape during the +// deprecation window; the warning points the user at the migration guide. +func WarnLegacyAgentShape(source AgentDefinitionSource) { + if !source.IsLegacy() { + return + } + legacyAgentShapeWarnOnce.Do(func() { + detail := "the deprecated config-nested azure.ai.agent shape" + if source == AgentDefinitionSourceDisk { + detail = "an on-disk agent.yaml/agent.yml" + } + fmt.Fprintf(os.Stderr, + "WARNING: this project uses %s. azd still reads it, but the shape is deprecated; "+ + "re-run `azd ai agent init` to move the agent definition into azure.yaml. See %s\n", + detail, MigrationGuideURL, + ) + }) +} + +// AgentDefinitionInline is the hosted-agent definition (formerly agent.yaml) +// carried as flat service-level properties on the azure.ai.agent service entry. +// +// It mirrors [agent_yaml.ContainerAgent] except for two fields that map onto core +// [azdext.ServiceConfig] fields instead of the inline property bag: the CPU/memory +// Resources (carried in the `container` config to avoid a key/type collision with +// the tool Resources list [ServiceTargetAgentConfig.Resources], also keyed +// `resources`), and Image (carried on the core `image` service field, since +// `image` is a first-class ServiceConfig field that core binds and round-trips). +// The embedded [agent_yaml.AgentDefinition] promotes kind, name, description, and +// the schema fields to the top level. +type AgentDefinitionInline struct { + agent_yaml.AgentDefinition `json:",inline"` + Protocols []agent_yaml.ProtocolVersionRecord `json:"protocols,omitempty"` + EnvironmentVariables *[]agent_yaml.EnvironmentVariable `json:"environmentVariables,omitempty"` + AgentEndpoint *agent_yaml.AgentEndpoint `json:"agentEndpoint,omitempty"` + AgentCard *agent_yaml.AgentCard `json:"agentCard,omitempty"` + CodeConfiguration *agent_yaml.CodeConfiguration `json:"codeConfiguration,omitempty"` + Policies []agent_yaml.Policy `json:"policies,omitempty"` +} + +// agentDefinitionToInline splits a ContainerAgent into the inline definition, +// the CPU/memory ContainerSettings (carried in the `container` config), and the +// prebuilt image (carried on the core `image` service field). The latter two are +// returned separately so the caller can place them on their respective homes. +func agentDefinitionToInline(ca agent_yaml.ContainerAgent) (AgentDefinitionInline, *ContainerSettings, string) { + inline := AgentDefinitionInline{ + AgentDefinition: ca.AgentDefinition, + Protocols: ca.Protocols, + EnvironmentVariables: ca.EnvironmentVariables, + AgentEndpoint: ca.AgentEndpoint, + AgentCard: ca.AgentCard, + CodeConfiguration: ca.CodeConfiguration, + Policies: ca.Policies, + } + + var container *ContainerSettings + if ca.Resources != nil { + container = &ContainerSettings{ + Resources: &ResourceSettings{Cpu: ca.Resources.Cpu, Memory: ca.Resources.Memory}, + } + } + + return inline, container, ca.Image +} + +// toContainerAgent rebuilds the agent_yaml.ContainerAgent from the inline +// definition, the CPU/memory carried in the `container` config, and the image +// carried on the core service field. +func (d AgentDefinitionInline) toContainerAgent(container *ContainerSettings, image string) agent_yaml.ContainerAgent { + ca := agent_yaml.ContainerAgent{ + AgentDefinition: d.AgentDefinition, + Image: image, + Protocols: d.Protocols, + EnvironmentVariables: d.EnvironmentVariables, + AgentEndpoint: d.AgentEndpoint, + AgentCard: d.AgentCard, + CodeConfiguration: d.CodeConfiguration, + Policies: d.Policies, + } + + if container != nil && container.Resources != nil { + ca.Resources = &agent_yaml.ContainerResources{ + Cpu: container.Resources.Cpu, + Memory: container.Resources.Memory, + } + } + + return ca +} + +// structHasKind reports whether the struct carries a non-empty string `kind`, +// the marker that an agent definition is present in a service entry's inline or +// config properties. +func structHasKind(s *structpb.Struct) bool { + if s == nil { + return false + } + v, ok := s.Fields["kind"] + if !ok { + return false + } + return v.GetStringValue() != "" +} + +// LoadAgentDefinition resolves the hosted-agent definition for an azure.ai.agent +// service. It prefers the unified inline shape (service-level properties), falls +// back to the deprecated config-nested shape, and finally to a legacy +// agent.yaml/agent.yml file on disk so older projects keep building and +// deploying during the deprecation window. +// +// It returns the parsed ContainerAgent, whether it is a hosted agent (false for +// other kinds), and the source the definition came from (see +// [AgentDefinitionSource.IsLegacy]). +func LoadAgentDefinition( + svc *azdext.ServiceConfig, + projectRoot string, +) (agent_yaml.ContainerAgent, bool, AgentDefinitionSource, error) { + ca, isHosted, found, source, err := AgentDefinitionFromService(svc) + if err != nil { + return agent_yaml.ContainerAgent{}, false, source, err + } + if found { + return ca, isHosted, source, nil + } + + // Fall back to a legacy agent.yaml/agent.yml on disk. + return agentDefinitionFromDisk(svc, projectRoot) +} + +// AgentDefinitionFromService returns the agent definition carried inline on the +// service entry — the unified service-level shape, or the deprecated +// config-nested shape. found is false when the entry carries no inline +// definition, in which case callers fall back to a legacy agent.yaml on disk. +func AgentDefinitionFromService( + svc *azdext.ServiceConfig, +) (agent_yaml.ContainerAgent, bool, bool, AgentDefinitionSource, error) { + inlineStruct := svc.GetAdditionalProperties() + source := AgentDefinitionSourceInline + if !structHasKind(inlineStruct) { + if cfg := svc.GetConfig(); structHasKind(cfg) { + inlineStruct = cfg + source = AgentDefinitionSourceLegacyConfig + } else { + return agent_yaml.ContainerAgent{}, false, false, source, nil + } + } + + ca, isHosted, err := agentDefinitionFromStruct(inlineStruct, svc.GetImage()) + return ca, isHosted, true, source, err +} + +// LoadServiceTargetAgentConfig reads the agent service's deploy/provision config +// (container settings, tool resources, tool connections, startup command, and — +// for pre-split projects — bundled deployments/connections/toolboxes) from the +// service-level properties, falling back to the deprecated config-nested shape. +func LoadServiceTargetAgentConfig(svc *azdext.ServiceConfig) (*ServiceTargetAgentConfig, error) { + s := ServiceConfigProps(svc) + cfg := &ServiceTargetAgentConfig{} + if s == nil { + return cfg, nil + } + if err := UnmarshalStruct(s, &cfg); err != nil { + return nil, err + } + return cfg, nil +} + +// ServiceConfigProps returns the agent service's service-level (inline) +// properties when present, otherwise the deprecated config-nested struct. It is +// the single accessor for code that needs the raw property struct regardless of +// which shape a project uses. +func ServiceConfigProps(svc *azdext.ServiceConfig) *structpb.Struct { + if s := svc.GetAdditionalProperties(); s != nil && len(s.GetFields()) > 0 { + return s + } + return svc.GetConfig() +} + +// UpsertAgentEnvVars adds or updates environment variables on the agent +// definition carried inline on the service entry, preserving every other key. +// It is used by commands that mutate the definition (e.g. `optimize apply`). +// Returns an error when the service carries no inline definition; callers fall +// back to mutating a legacy on-disk agent.yaml in that case. +func UpsertAgentEnvVars(svc *azdext.ServiceConfig, kv map[string]string) error { + ca, _, found, source, err := AgentDefinitionFromService(svc) + if err != nil { + return err + } + if !found { + return fmt.Errorf("service %q does not carry an inline agent definition", svc.GetName()) + } + + envVars := []agent_yaml.EnvironmentVariable{} + if ca.EnvironmentVariables != nil { + envVars = *ca.EnvironmentVariables + } + for key, value := range kv { + idx := -1 + for i := range envVars { + if envVars[i].Name == key { + idx = i + break + } + } + if idx >= 0 { + envVars[idx].Value = value + } else { + envVars = append(envVars, agent_yaml.EnvironmentVariable{Name: key, Value: value}) + } + } + ca.EnvironmentVariables = &envVars + + cfg, err := LoadServiceTargetAgentConfig(svc) + if err != nil { + return err + } + + props, err := AgentDefinitionToServiceProperties(ca, cfg) + if err != nil { + return err + } + + if source == AgentDefinitionSourceLegacyConfig { + svc.Config = props + } else { + svc.AdditionalProperties = props + } + return nil +} + +// SetAgentContainerSettings writes the resolved container settings onto the +// agent service's inline properties, preserving every other key (the agent +// definition and the rest of the deploy/provision config). It mutates whichever +// shape the service uses (the unified AdditionalProperties, or — for older +// projects — the config-nested struct). +func SetAgentContainerSettings(svc *azdext.ServiceConfig, container *ContainerSettings) error { + legacy := false + props := svc.GetAdditionalProperties() + if props == nil || len(props.GetFields()) == 0 { + if cfg := svc.GetConfig(); cfg != nil && len(cfg.GetFields()) > 0 { + props = cfg + legacy = true + } else { + props = &structpb.Struct{} + } + } + if props.Fields == nil { + props.Fields = map[string]*structpb.Value{} + } + + containerStruct, err := MarshalStruct(container) + if err != nil { + return fmt.Errorf("marshaling container settings: %w", err) + } + props.Fields["container"] = structpb.NewStructValue(containerStruct) + + if legacy { + svc.Config = props + } else { + svc.AdditionalProperties = props + } + return nil +} + +// agentDefinitionFromStruct builds the ContainerAgent from an inline/config +// struct that carries the agent definition as service-level properties. coreImage +// is the value of the service's `image` field, which is carried on the core +// [azdext.ServiceConfig] rather than in the inline property bag. +func agentDefinitionFromStruct(s *structpb.Struct, coreImage string) (agent_yaml.ContainerAgent, bool, error) { + var inline AgentDefinitionInline + if err := UnmarshalStruct(s, &inline); err != nil { + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent service config is not valid: %s", err), + "re-run `azd ai agent init` to regenerate the agent service entry", + ) + } + + if inline.Kind != agent_yaml.AgentKindHosted { + return agent_yaml.ContainerAgent{}, false, nil + } + + var cfg ServiceTargetAgentConfig + if err := UnmarshalStruct(s, &cfg); err != nil { + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("agent service config is not valid: %s", err), + "re-run `azd ai agent init` to regenerate the agent service entry", + ) + } + + ca := inline.toContainerAgent(cfg.Container, coreImage) + + // Validate the inline definition with the same rules the on-disk agent.yaml + // path uses (kind, name format, policies), so an inline definition cannot + // silently bypass validation. Marshal back to YAML so ValidateAgentDefinition + // sees the same shape it expects from disk. + if defBytes, marshalErr := yaml.Marshal(ca); marshalErr != nil { + // A ContainerAgent should always marshal; log at debug so a regression + // here is visible during troubleshooting rather than silently skipping + // validation. + log.Printf("[debug] skipping inline agent definition validation: marshal to YAML failed: %v", marshalErr) + } else if err := agent_yaml.ValidateAgentDefinition(defBytes); err != nil { + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent service definition is not valid: %s", err), + "fix the agent service entry in azure.yaml or re-run `azd ai agent init`", + ) + } + + if ca.Image != "" && !containerImageRefRe.MatchString(ca.Image) { + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("invalid container image reference in agent service config: %q", ca.Image), + "use a valid image reference, e.g. 'myregistry.azurecr.io/image:v1'", + ) + } + + return ca, true, nil +} + +// agentDefinitionFromDisk reads a legacy agent.yaml/agent.yml from the service +// directory. This is the deprecation fallback for projects written before the +// definition moved into azure.yaml. +func agentDefinitionFromDisk( + svc *azdext.ServiceConfig, + projectRoot string, +) (agent_yaml.ContainerAgent, bool, AgentDefinitionSource, error) { + for _, name := range []string{"agent.yaml", "agent.yml"} { + defPath, err := paths.JoinAllowRoot(projectRoot, svc.GetRelativePath(), name) + if err != nil { + return agent_yaml.ContainerAgent{}, false, AgentDefinitionSourceDisk, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("invalid service path for %s: %s", svc.GetName(), err), + "update azure.yaml so the agent service path stays within the project directory", + ) + } + data, err := os.ReadFile(defPath) //nolint:gosec // path derived from azd project config + if err != nil { + continue + } + ca, isHosted, err := parseContainerAgentYAML(data) + return ca, isHosted, AgentDefinitionSourceDisk, err + } + + return agent_yaml.ContainerAgent{}, false, AgentDefinitionSourceDisk, exterrors.Dependency( + exterrors.CodeAgentDefinitionNotFound, + fmt.Sprintf("agent definition not found for service %q", svc.GetName()), + "re-run `azd ai agent init` to write the agent definition into azure.yaml", + ) +} + +// parseContainerAgentYAML validates and parses agent.yaml bytes into a +// ContainerAgent, mirroring the on-disk loader used before the unified shape. +func parseContainerAgentYAML(data []byte) (agent_yaml.ContainerAgent, bool, error) { + if err := agent_yaml.ValidateAgentDefinition(data); err != nil { + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent.yaml is not valid: %s", err), + "fix the agent.yaml file according to the schema", + ) + } + + var genericTemplate map[string]any + if err := yaml.Unmarshal(data, &genericTemplate); err != nil { + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("YAML content is not valid: %s", err), + "verify the agent.yaml has valid YAML syntax", + ) + } + + kind, ok := genericTemplate["kind"].(string) + if !ok { + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeMissingAgentKind, + "kind field is missing or not a valid string in agent.yaml", + "add a valid 'kind' field (e.g., 'hosted') to agent.yaml", + ) + } + + if kind != string(agent_yaml.AgentKindHosted) { + return agent_yaml.ContainerAgent{}, false, nil + } + + var agentDef agent_yaml.ContainerAgent + if err := yaml.Unmarshal(data, &agentDef); err != nil { + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("YAML content is not valid for hosted agent: %s", err), + "fix the agent.yaml to match the hosted agent schema", + ) + } + + if agentDef.Image != "" && !containerImageRefRe.MatchString(agentDef.Image) { + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("invalid container image reference in agent.yaml: %q", agentDef.Image), + "use a valid image reference, e.g. 'myregistry.azurecr.io/image:v1'", + ) + } + + return agentDef, true, nil +} + +// AgentDefinitionToServiceProperties marshals a ContainerAgent into the inline +// service-level properties (and the `container` CPU/memory config) used by the +// unified azure.ai.agent service entry. The returned struct is merged into the +// service entry's AdditionalProperties at init time. +// +// Note: the agent's prebuilt `image` is NOT included here — it maps onto the core +// [azdext.ServiceConfig.Image] field, which the caller must set from ca.Image so +// it round-trips through azure.yaml. +func AgentDefinitionToServiceProperties( + ca agent_yaml.ContainerAgent, + extra *ServiceTargetAgentConfig, +) (*structpb.Struct, error) { + inline, container, _ := agentDefinitionToInline(ca) + + defStruct, err := MarshalStruct(&inline) + if err != nil { + return nil, fmt.Errorf("marshaling agent definition: %w", err) + } + + cfg := ServiceTargetAgentConfig{} + if extra != nil { + cfg = *extra + } + if container != nil { + cfg.Container = container + } + + cfgStruct, err := MarshalStruct(&cfg) + if err != nil { + return nil, fmt.Errorf("marshaling agent service config: %w", err) + } + + // Merge the deploy/provision config keys onto the definition keys. The two + // sets are disjoint except `container`, which only the config carries. + for k, v := range cfgStruct.GetFields() { + defStruct.Fields[k] = v + } + + return defStruct, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go new file mode 100644 index 00000000000..7bd880f29a5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "os" + "path/filepath" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// sampleContainerAgent returns a hosted ContainerAgent with the fields that the +// unified inline shape must round-trip. +func sampleContainerAgent() agent_yaml.ContainerAgent { + return agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindHosted, + Name: "basic-agent", + Description: new("A basic agent hosted by Foundry."), + }, + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "1.0.0"}, + }, + EnvironmentVariables: &[]agent_yaml.EnvironmentVariable{ + {Name: "FOUNDRY_MODEL_DEPLOYMENT_NAME", Value: "gpt-4.1-mini"}, + }, + Resources: &agent_yaml.ContainerResources{Cpu: "1", Memory: "2Gi"}, + } +} + +// TestAgentDefinitionRoundTrip verifies that a hosted agent definition plus the +// deploy/provision config survive a marshal into the inline service properties +// and back, including the key/type collision between the CPU/memory `resources` +// (container) and the tool `resources` list. +func TestAgentDefinitionRoundTrip(t *testing.T) { + ca := sampleContainerAgent() + extra := &ServiceTargetAgentConfig{ + StartupCommand: "python main.py", + Resources: []Resource{ + {Resource: "bing_grounding", ConnectionName: "bing"}, + }, + ToolConnections: []ToolConnection{ + {Name: "mcp", Category: "RemoteTool", Target: "https://example", AuthType: "None"}, + }, + } + + props, err := AgentDefinitionToServiceProperties(ca, extra) + require.NoError(t, err) + + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + + got, isHosted, found, source, err := AgentDefinitionFromService(svc) + require.NoError(t, err) + require.True(t, found) + require.True(t, isHosted) + require.Equal(t, AgentDefinitionSourceInline, source) + require.False(t, source.IsLegacy()) + + require.Equal(t, "basic-agent", got.Name) + require.NotNil(t, got.Description) + require.Equal(t, "A basic agent hosted by Foundry.", *got.Description) + require.Equal(t, ca.Protocols, got.Protocols) + require.NotNil(t, got.EnvironmentVariables) + require.Equal(t, *ca.EnvironmentVariables, *got.EnvironmentVariables) + // CPU/memory round-trips through the `container` config. + require.NotNil(t, got.Resources) + require.Equal(t, "1", got.Resources.Cpu) + require.Equal(t, "2Gi", got.Resources.Memory) + + // The deploy/provision config survives alongside the definition. The tool + // `resources` list must NOT be clobbered by the CPU/memory `resources`. + cfg, err := LoadServiceTargetAgentConfig(svc) + require.NoError(t, err) + require.Equal(t, "python main.py", cfg.StartupCommand) + require.Len(t, cfg.Resources, 1) + require.Equal(t, "bing_grounding", cfg.Resources[0].Resource) + require.Len(t, cfg.ToolConnections, 1) + require.NotNil(t, cfg.Container) + require.NotNil(t, cfg.Container.Resources) + require.Equal(t, "1", cfg.Container.Resources.Cpu) +} + +// TestAgentDefinitionFromService_LegacyConfigShape verifies that a definition +// stored under the deprecated config-nested shape is detected as legacy. +func TestAgentDefinitionFromService_LegacyConfigShape(t *testing.T) { + props, err := AgentDefinitionToServiceProperties(sampleContainerAgent(), nil) + require.NoError(t, err) + + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + Config: props, // old config-nested shape + } + + got, isHosted, found, source, err := AgentDefinitionFromService(svc) + require.NoError(t, err) + require.True(t, found) + require.True(t, isHosted) + require.Equal(t, AgentDefinitionSourceLegacyConfig, source) + require.True(t, source.IsLegacy()) + require.Equal(t, "basic-agent", got.Name) +} + +// TestAgentDefinitionFromService_NoDefinition verifies that a service without an +// inline definition reports not-found (callers then fall back to disk). +func TestAgentDefinitionFromService_NoDefinition(t *testing.T) { + svc := &azdext.ServiceConfig{Name: "basic-agent", Host: "azure.ai.agent"} + _, _, found, _, err := AgentDefinitionFromService(svc) + require.NoError(t, err) + require.False(t, found) +} + +// TestAgentDefinition_ImageRidesOnCoreServiceField verifies the prebuilt image +// maps onto the core ServiceConfig.Image field (which core binds and round-trips) +// rather than the inline property bag, where core would strip it on reload. +func TestAgentDefinition_ImageRidesOnCoreServiceField(t *testing.T) { + const image = "myregistry.azurecr.io/img:v1" + ca := sampleContainerAgent() + ca.Image = image + + props, err := AgentDefinitionToServiceProperties(ca, nil) + require.NoError(t, err) + // image must NOT be carried in the inline AdditionalProperties: core binds + // the typed `image` field, so an inline `image` key is dropped on reload. + _, hasInlineImage := props.GetFields()["image"] + require.False(t, hasInlineImage, "image must not be carried in inline AdditionalProperties") + + // The definition reads its image back from the core service field. + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + Image: image, + AdditionalProperties: props, + } + got, isHosted, found, _, err := AgentDefinitionFromService(svc) + require.NoError(t, err) + require.True(t, found) + require.True(t, isHosted) + require.Equal(t, image, got.Image) + + // With no core image field, image is empty — proving it is not in props. + svcNoImage := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + gotNoImage, _, _, _, err := AgentDefinitionFromService(svcNoImage) + require.NoError(t, err) + require.Empty(t, gotNoImage.Image) +} + +// TestAgentDefinitionFromService_InvalidImage verifies the image reference (from +// the core service field) is still validated for the inline shape. +func TestAgentDefinitionFromService_InvalidImage(t *testing.T) { + props, err := AgentDefinitionToServiceProperties(sampleContainerAgent(), nil) + require.NoError(t, err) + + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + Image: "not a valid image ref", + AdditionalProperties: props, + } + _, _, _, _, err = AgentDefinitionFromService(svc) + require.Error(t, err) +} + +// TestAgentDefinitionFromService_InvalidDefinition verifies that inline +// definitions get the same structural validation as the on-disk agent.yaml path, +// so a malformed definition (e.g. an invalid agent name) is not silently used. +func TestAgentDefinitionFromService_InvalidDefinition(t *testing.T) { + ca := sampleContainerAgent() + ca.Name = "Invalid Name!" // fails ValidateAgentName + props, err := AgentDefinitionToServiceProperties(ca, nil) + require.NoError(t, err) + + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + _, _, _, _, err = AgentDefinitionFromService(svc) + require.Error(t, err) +} + +// TestLoadAgentDefinition_DiskFallback verifies the legacy on-disk agent.yaml +// fallback used during the migration window. +func TestLoadAgentDefinition_DiskFallback(t *testing.T) { + dir := t.TempDir() + yaml := "kind: hosted\nname: disk-agent\nprotocols:\n - protocol: responses\n version: \"1.0.0\"\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(yaml), 0o600)) + + svc := &azdext.ServiceConfig{Name: "disk-agent", Host: "azure.ai.agent", RelativePath: "."} + got, isHosted, source, err := LoadAgentDefinition(svc, dir) + require.NoError(t, err) + require.True(t, isHosted) + require.Equal(t, AgentDefinitionSourceDisk, source) + require.True(t, source.IsLegacy()) + require.Equal(t, "disk-agent", got.Name) +} + +// TestUpsertAgentEnvVars verifies that env vars are added/updated on the inline +// definition while preserving the other definition keys. +func TestUpsertAgentEnvVars(t *testing.T) { + props, err := AgentDefinitionToServiceProperties(sampleContainerAgent(), nil) + require.NoError(t, err) + svc := &azdext.ServiceConfig{Name: "basic-agent", Host: "azure.ai.agent", AdditionalProperties: props} + + require.NoError(t, UpsertAgentEnvVars(svc, map[string]string{ + "FOUNDRY_MODEL_DEPLOYMENT_NAME": "gpt-4o", // update existing + "OPTIMIZATION_CANDIDATE_ID": "cand-1", // add new + })) + + got, _, found, _, err := AgentDefinitionFromService(svc) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, "basic-agent", got.Name) // other keys preserved + require.NotNil(t, got.EnvironmentVariables) + + values := map[string]string{} + for _, ev := range *got.EnvironmentVariables { + values[ev.Name] = ev.Value + } + require.Equal(t, "gpt-4o", values["FOUNDRY_MODEL_DEPLOYMENT_NAME"]) + require.Equal(t, "cand-1", values["OPTIMIZATION_CANDIDATE_ID"]) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go index 74b0b3cf137..0e859980da3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "log" + "net/url" "os" "path/filepath" "slices" @@ -71,6 +72,11 @@ type FoundryProvisioningProvider struct { armTemplate map[string]any // embedded ARM JSON; nil when onDiskSource is set onDiskSource *templateSource // non-nil when ./infra/main.{bicep,bicepparam} exists + // brownfieldEndpoint is the existing project endpoint when the foundry + // service sets endpoint: (bring-your-own). When non-empty the provider skips + // provisioning and connects to that project instead of creating a new one. + brownfieldEndpoint string + // Lazily constructed on first compile. nil until needed. bicepCliInstance bicepCompiler } @@ -126,9 +132,12 @@ func (p *FoundryProvisioningProvider) Initialize( if p.onDiskTemplatePresent() { log.Printf("[debug] foundry provider: on-disk Bicep detected under %s; "+ "skipping synthesizer", filepath.Join(projectPath, onDiskInfraDir)) - // endpoint: (brownfield) is rejected even on the on-disk path. - if err := rejectBrownfield(rawYAML, svcName); err != nil { - return err + // endpoint: (brownfield) reuse skips provisioning even on the on-disk + // path; connect to the existing project instead of compiling Bicep. + if endpoint := foundryServiceEndpoint(rawYAML, svcName); endpoint != "" { + warnNetworkIgnoredInBrownfield(rawYAML, svcName) + p.brownfieldEndpoint = endpoint + return p.resolveEnvName(ctx) } return p.resolveEnv(ctx) } @@ -141,14 +150,11 @@ func (p *FoundryProvisioningProvider) Initialize( }) switch { case errors.Is(err, synthesis.ErrEndpointBrownfield): + // endpoint: reuse — connect to the existing project, skip provisioning. // network: has no effect in brownfield mode; warn if both are present. warnNetworkIgnoredInBrownfield(rawYAML, svcName) - return exterrors.Validation( - exterrors.CodeBrownfieldNotSupported, - "endpoint: is set on the foundry service; existing-project (brownfield) "+ - "provisioning is not supported yet", - "remove endpoint: to provision a new Foundry project, or switch infra.provider to bicep", - ) + p.brownfieldEndpoint = foundryServiceEndpoint(rawYAML, svcName) + return p.resolveEnvName(ctx) case errors.Is(err, synthesis.ErrServiceNotFound): return exterrors.Dependency( exterrors.CodeProvisioningServiceNotFound, @@ -229,7 +235,7 @@ func warnNetworkIgnoredInBrownfield(rawYAML []byte, svcName string) { return } s := r.Services[svcName] - if s.Endpoint != "" && !s.Network.IsZero() { + if strings.TrimSpace(s.Endpoint) != "" && !s.Network.IsZero() { log.Printf("[warn] foundry provider: service %q sets both endpoint: and network:; "+ "network: is ignored in brownfield mode (the account's network posture is fixed)", svcName) } @@ -242,9 +248,11 @@ func (p *FoundryProvisioningProvider) onDiskTemplatePresent() bool { fileExistsAt(filepath.Join(infraDir, onDiskBicepFile)) } -// rejectBrownfield refuses an on-disk service that sets endpoint:, matching -// the synthesizer's ErrEndpointBrownfield branch (which the on-disk path skips). -func rejectBrownfield(rawYAML []byte, svcName string) error { +// foundryServiceEndpoint returns the endpoint: value set on the named foundry +// service, or "" when none is set. A non-empty endpoint means bring-your-own +// (brownfield): the provider connects to that existing project instead of +// provisioning a new one. +func foundryServiceEndpoint(rawYAML []byte, svcName string) string { type svc struct { Endpoint string `yaml:"endpoint,omitempty"` } @@ -254,17 +262,55 @@ func rejectBrownfield(rawYAML []byte, svcName string) error { var r root if err := yaml.Unmarshal(rawYAML, &r); err != nil { // Malformed yaml is surfaced upstream; don't mask the parser error. - return nil + return "" } - if r.Services[svcName].Endpoint == "" { - return nil + return strings.TrimSpace(r.Services[svcName].Endpoint) +} + +// resolveEnvName resolves just the active azd environment name. The brownfield +// (endpoint:) path uses it instead of resolveEnv because connecting to an +// existing project needs no subscription, location, or resource group. +func (p *FoundryProvisioningProvider) resolveEnvName(ctx context.Context) error { + currEnv, err := p.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return exterrors.Dependency( + exterrors.CodeEnvironmentNotFound, + fmt.Sprintf("get current azd environment: %s", err), + "run 'azd env new' to create an environment", + ) } - return exterrors.Validation( - exterrors.CodeBrownfieldNotSupported, - "endpoint: is set on the foundry service; existing-project (brownfield) "+ - "provisioning is not supported yet", - "remove endpoint: to provision a new Foundry project, or switch infra.provider to bicep", - ) + p.envName = currEnv.Environment.Name + return nil +} + +// brownfieldOutputs builds the provisioning outputs for a bring-your-own +// project: the endpoint downstream services consume, plus the project name +// parsed from it when present. +func brownfieldOutputs(endpoint string) map[string]*azdext.ProvisioningOutputParameter { + outputs := map[string]*azdext.ProvisioningOutputParameter{ + "FOUNDRY_PROJECT_ENDPOINT": {Type: "string", Value: endpoint}, + } + if name := projectNameFromEndpoint(endpoint); name != "" { + outputs["AZURE_AI_PROJECT_NAME"] = &azdext.ProvisioningOutputParameter{Type: "string", Value: name} + } + return outputs +} + +// projectNameFromEndpoint extracts the project name from a Foundry project +// endpoint of the form https://.services.ai.azure.com/api/projects/. +// Returns "" when the path does not carry a project segment. +func projectNameFromEndpoint(endpoint string) string { + u, err := url.Parse(endpoint) + if err != nil { + return "" + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + for i := 0; i+1 < len(parts); i++ { + if parts[i] == "projects" { + return parts[i+1] + } + } + return "" } // resolveEnv pulls the env values the provider needs from azd-core. It does @@ -375,6 +421,15 @@ func (p *FoundryProvisioningProvider) State( ctx context.Context, options *azdext.ProvisioningStateOptions, ) (*azdext.ProvisioningStateResult, error) { + if p.brownfieldEndpoint != "" { + return &azdext.ProvisioningStateResult{ + State: &azdext.ProvisioningState{ + Outputs: brownfieldOutputs(p.brownfieldEndpoint), + Resources: []*azdext.ProvisioningResource{}, + }, + }, nil + } + client, err := p.deploymentsClient(ctx) if err != nil { return nil, err @@ -410,6 +465,15 @@ func (p *FoundryProvisioningProvider) Deploy( ctx context.Context, progress grpcbroker.ProgressFunc, ) (*azdext.ProvisioningDeployResult, error) { + if p.brownfieldEndpoint != "" { + progress("Using existing Foundry project (endpoint set); skipping provisioning") + return &azdext.ProvisioningDeployResult{ + Deployment: &azdext.ProvisioningDeployment{ + Outputs: brownfieldOutputs(p.brownfieldEndpoint), + }, + }, nil + } + progress("Preparing Foundry provisioning template...") // provision.network_mode telemetry: none | byo | managed. Lets us measure @@ -592,6 +656,13 @@ func (p *FoundryProvisioningProvider) Preview( ctx context.Context, progress grpcbroker.ProgressFunc, ) (*azdext.ProvisioningPreviewResult, error) { + if p.brownfieldEndpoint != "" { + progress("Using existing Foundry project (endpoint set); nothing to provision") + return &azdext.ProvisioningPreviewResult{ + Preview: &azdext.ProvisioningDeploymentPreview{}, + }, nil + } + progress("Computing deployment plan...") src, err := p.resolveTemplate(ctx, progress) @@ -665,6 +736,12 @@ func (p *FoundryProvisioningProvider) Destroy( options *azdext.ProvisioningDestroyOptions, progress grpcbroker.ProgressFunc, ) (*azdext.ProvisioningDestroyResult, error) { + if p.brownfieldEndpoint != "" { + progress("Foundry project is bring-your-own (endpoint set); azd did not " + + "create it, so azd down leaves it in place") + return &azdext.ProvisioningDestroyResult{}, nil + } + if !options.GetForce() { return nil, exterrors.Validation( exterrors.CodeDestroyRequiresForce, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go index 42fd31b4633..f3fe159e9ee 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go @@ -35,6 +35,15 @@ func TestFindFoundryService(t *testing.T) { services: my-project: host: azure.ai.agent +`, + want: "my-project", + }, + { + name: "legacy microsoft.foundry service host", + yaml: ` +services: + my-project: + host: microsoft.foundry `, want: "my-project", }, @@ -67,6 +76,17 @@ services: host: azure.ai.agent b: host: azure.ai.agent +`, + wantErr: true, + }, + { + name: "new and legacy foundry services rejected as ambiguous", + yaml: ` +services: + a: + host: azure.ai.agent + b: + host: microsoft.foundry `, wantErr: true, }, @@ -719,65 +739,86 @@ func TestResolveTemplate_OnDiskFallsBackWhenSourceLoaderReturnsNil(t *testing.T) assert.Equal(t, templateModeEmbedded, got.mode) } -func TestRejectBrownfield(t *testing.T) { +func TestFoundryServiceEndpoint(t *testing.T) { t.Parallel() tests := []struct { - name string - yaml string - svcName string - wantError bool + name string + yaml string + svcName string + wantEndpoint string }{ { - name: "greenfield (no endpoint:) -> nil", + name: "greenfield (no endpoint:) -> empty", yaml: `name: x services: foundry: host: azure.ai.agent`, - svcName: "foundry", - wantError: false, + svcName: "foundry", + wantEndpoint: "", }, { - name: "endpoint set -> brownfield error", + name: "endpoint set -> returned for brownfield reuse", yaml: `name: x services: foundry: host: azure.ai.agent endpoint: https://example.foundry.example.com`, - svcName: "foundry", - wantError: true, + svcName: "foundry", + wantEndpoint: "https://example.foundry.example.com", + }, + { + name: "blank endpoint -> empty", + yaml: `name: x +services: + foundry: + host: azure.ai.agent + endpoint: " "`, + svcName: "foundry", + wantEndpoint: "", }, { - name: "service not in yaml -> nil (not our error to raise)", + name: "service not in yaml -> empty", yaml: `name: x services: other: host: containerapp`, - svcName: "foundry", - wantError: false, + svcName: "foundry", + wantEndpoint: "", }, { - name: "malformed yaml -> nil (upstream surfaces parse error)", - yaml: "not: : valid: yaml", - svcName: "foundry", - wantError: false, + name: "malformed yaml -> empty (upstream surfaces parse error)", + yaml: "not: : valid: yaml", + svcName: "foundry", + wantEndpoint: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := rejectBrownfield([]byte(tt.yaml), tt.svcName) - if !tt.wantError { - assert.NoError(t, err) - return - } - require.Error(t, err) - var local *azdext.LocalError - require.True(t, errors.As(err, &local)) - assert.Equal(t, exterrors.CodeBrownfieldNotSupported, local.Code) + assert.Equal(t, tt.wantEndpoint, foundryServiceEndpoint([]byte(tt.yaml), tt.svcName)) }) } } +func TestProjectNameFromEndpoint(t *testing.T) { + t.Parallel() + assert.Equal(t, "my-project", projectNameFromEndpoint( + "https://acct.services.ai.azure.com/api/projects/my-project")) + assert.Equal(t, "", projectNameFromEndpoint("https://acct.services.ai.azure.com")) + assert.Equal(t, "", projectNameFromEndpoint("")) +} + +func TestBrownfieldOutputs(t *testing.T) { + t.Parallel() + outputs := brownfieldOutputs("https://acct.services.ai.azure.com/api/projects/my-project") + require.Contains(t, outputs, "FOUNDRY_PROJECT_ENDPOINT") + assert.Equal(t, + "https://acct.services.ai.azure.com/api/projects/my-project", + outputs["FOUNDRY_PROJECT_ENDPOINT"].Value) + require.Contains(t, outputs, "AZURE_AI_PROJECT_NAME") + assert.Equal(t, "my-project", outputs["AZURE_AI_PROJECT_NAME"].Value) +} + func TestEnvValues_IncludesCanonicalKeysEvenWithoutAzdClient(t *testing.T) { t.Parallel() // envValues must always include the canonical AZURE_* keys diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/provisioning_provider.go b/cli/azd/extensions/azure.ai.agents/internal/project/provisioning_provider.go index 1e047e4729a..cce5f750a7b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/provisioning_provider.go @@ -17,8 +17,10 @@ const ( TerraformProviderName = "terraform" ) -// FoundryServiceHosts lists the values of `services..host` that -// this extension's provisioning provider treats as Foundry services. -// Must stay in sync with cmd.AiAgentHost ("azure.ai.agent") — kept here -// to avoid a cmd -> project import cycle. -var FoundryServiceHosts = []string{"azure.ai.agent"} +// FoundryServiceHosts lists the values of `services..host` that this +// extension's provisioning provider treats as Foundry services. Keep +// "azure.ai.agent" first so suggestions point users at the unified host while +// "microsoft.foundry" remains accepted for existing projects during migration. +// Must stay in sync with cmd.AiAgentHost ("azure.ai.agent") — kept here to avoid +// a cmd -> project import cycle. +var FoundryServiceHosts = []string{"azure.ai.agent", "microsoft.foundry"} 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 bd7fb81865b..63e255439c2 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 @@ -39,7 +39,6 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/output" - "github.com/braydonk/yaml" "github.com/fatih/color" "github.com/google/uuid" "google.golang.org/protobuf/types/known/structpb" @@ -152,6 +151,9 @@ type AgentServiceTargetProvider struct { azdClient *azdext.AzdClient serviceConfig *azdext.ServiceConfig agentDefinitionPath string + projectPath string + servicePath string + deployContextReady bool credential *azidentity.AzureDeveloperCLICredential tenantId string env *azdext.Environment @@ -189,7 +191,7 @@ func (p *AgentServiceTargetProvider) Initialize(ctx context.Context, serviceConf // environment, the tenant, and the credential. Idempotent via the // agentDefinitionPath short-circuit. func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) error { - if p.agentDefinitionPath != "" { + if p.deployContextReady || p.agentDefinitionPath != "" { return nil } if p.serviceConfig == nil { @@ -268,7 +270,8 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er } p.credential = cred - fmt.Fprintf(os.Stderr, "Project path: %s, Service path: %s\n", proj.Project.Path, fullPath) + p.projectPath = proj.Project.Path + p.servicePath = fullPath // Check if user has specified agent definition path via environment variable if envPath := os.Getenv("AGENT_DEFINITION_PATH"); envPath != "" { @@ -293,10 +296,20 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er p.agentDefinitionPath = envPath fmt.Printf("Using agent definition from environment variable: %s\n", color.New(color.FgHiGreen).Sprint(envPath)) + p.deployContextReady = true + return nil + } + + // Unified shape: the agent definition is carried inline on the service entry, + // so no on-disk agent.yaml is required. + if _, _, found, _, defErr := AgentDefinitionFromService(p.serviceConfig); defErr != nil { + return defErr + } else if found { + p.deployContextReady = true return nil } - // Look for agent.yaml or agent.yml in the service directory root + // Legacy shape: look for agent.yaml or agent.yml in the service directory root agentYamlPath, err := paths.JoinAllowRoot(proj.Project.Path, servicePath, "agent.yaml") if err != nil { return exterrors.Validation( @@ -317,12 +330,14 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er if _, err := os.Stat(agentYamlPath); err == nil { p.agentDefinitionPath = agentYamlPath fmt.Printf("Using agent definition: %s\n", color.New(color.FgHiGreen).Sprint(agentYamlPath)) + p.deployContextReady = true return nil } if _, err := os.Stat(agentYmlPath); err == nil { p.agentDefinitionPath = agentYmlPath fmt.Printf("Using agent definition: %s\n", color.New(color.FgHiGreen).Sprint(agentYmlPath)) + p.deployContextReady = true return nil } @@ -944,63 +959,36 @@ func hasContainerArtifact(artifacts []*azdext.Artifact) bool { } func (p *AgentServiceTargetProvider) loadContainerAgentDefinition() (agent_yaml.ContainerAgent, bool, error) { - data, err := os.ReadFile(p.agentDefinitionPath) - if err != nil { - return agent_yaml.ContainerAgent{}, false, 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 := agent_yaml.ValidateAgentDefinition(data); err != nil { - return agent_yaml.ContainerAgent{}, false, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("agent.yaml is not valid: %s", err), - "fix the agent.yaml file according to the schema", - ) - } - - var genericTemplate map[string]any - if err := yaml.Unmarshal(data, &genericTemplate); err != nil { - return agent_yaml.ContainerAgent{}, false, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("YAML content is not valid: %s", err), - "verify the agent.yaml has valid YAML syntax", - ) - } - - kind, ok := genericTemplate["kind"].(string) - if !ok { - return agent_yaml.ContainerAgent{}, false, exterrors.Validation( - exterrors.CodeMissingAgentKind, - "kind field is missing or not a valid string in agent.yaml", - "add a valid 'kind' field (e.g., 'hosted') to agent.yaml", - ) - } - - if kind != string(agent_yaml.AgentKindHosted) { - return agent_yaml.ContainerAgent{}, false, nil - } + // An explicit AGENT_DEFINITION_PATH override is represented by + // agentDefinitionPath and must win over the service entry. + if p.agentDefinitionPath != "" { + data, err := os.ReadFile(p.agentDefinitionPath) + if err != nil { + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("failed to read agent manifest file: %s", err), + "verify the agent.yaml file exists and is readable", + ) + } - var agentDef agent_yaml.ContainerAgent - if err := yaml.Unmarshal(data, &agentDef); err != nil { - return agent_yaml.ContainerAgent{}, false, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("YAML content is not valid for hosted agent: %s", err), - "fix the agent.yaml to match the hosted agent schema", - ) + WarnLegacyAgentShape(AgentDefinitionSourceDisk) + return parseContainerAgentYAML(data) } - if agentDef.Image != "" && !containerImageRefRe.MatchString(agentDef.Image) { - return agent_yaml.ContainerAgent{}, false, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("invalid container image reference in agent.yaml: %q", agentDef.Image), - "use a valid image reference, e.g. 'myregistry.azurecr.io/image:v1'", - ) + // Prefer the agent definition carried inline on the service entry (the + // unified service-level shape, or the deprecated config-nested shape). + if ca, isHosted, found, source, err := AgentDefinitionFromService(p.serviceConfig); found || err != nil { + if found && source.IsLegacy() { + WarnLegacyAgentShape(source) + } + return ca, isHosted, err } - return agentDef, true, nil + return agent_yaml.ContainerAgent{}, false, exterrors.Dependency( + exterrors.CodeAgentDefinitionNotFound, + fmt.Sprintf("agent definition not found for service %q", p.serviceConfig.GetName()), + "re-run `azd ai agent init` to write the agent definition into azure.yaml", + ) } // Deploy performs the deployment operation for the agent service @@ -1036,8 +1024,8 @@ func (p *AgentServiceTargetProvider) Deploy( azdEnv[kval.Key] = kval.Value } - var serviceTargetConfig *ServiceTargetAgentConfig - if err := UnmarshalStruct(serviceConfig.Config, &serviceTargetConfig); err != nil { + serviceTargetConfig, err := LoadServiceTargetAgentConfig(serviceConfig) + if err != nil { return nil, exterrors.Validation( exterrors.CodeInvalidServiceConfig, fmt.Sprintf("failed to parse service target config: %s", err), @@ -1049,7 +1037,7 @@ func (p *AgentServiceTargetProvider) Deploy( fmt.Println("Loaded custom service target configuration") } - warnDeprecatedScaleSettings(serviceConfig.Config) + warnDeprecatedScaleSettings(ServiceConfigProps(serviceConfig)) agentDef, isContainerAgent, err := p.loadContainerAgentDefinition() if err != nil { @@ -1163,29 +1151,14 @@ func (p *AgentServiceTargetProvider) shouldSkipACRForEnvironment(ctx context.Con return strings.EqualFold(strings.TrimSpace(resp.Value), "true") } -// isCodeDeployAgent returns true if the agent.yaml has code_configuration (code deploy mode) +// isCodeDeployAgent returns true if the agent definition has code_configuration (code deploy mode) func (p *AgentServiceTargetProvider) isCodeDeployAgent() bool { - data, err := os.ReadFile(p.agentDefinitionPath) - if err != nil { - return false - } - - var genericTemplate map[string]any - if err := yaml.Unmarshal(data, &genericTemplate); err != nil { - return false - } - - kind, ok := genericTemplate["kind"].(string) - if !ok { - return false - } - - if kind != string(agent_yaml.AgentKindHosted) { + agentDef, isHosted, err := p.loadContainerAgentDefinition() + if err != nil || !isHosted { return false } - _, hasCodeConfig := genericTemplate["code_configuration"] - return hasCodeConfig + return agentDef.CodeConfiguration != nil } // deployPrepResult holds the common outputs from prepareDeploy, used by both @@ -1236,7 +1209,9 @@ func (p *AgentServiceTargetProvider) prepareDeploy( ) } - fmt.Fprintf(os.Stderr, "Loaded configuration from: %s\n", p.agentDefinitionPath) + if p.agentDefinitionPath != "" { + fmt.Fprintf(os.Stderr, "Loaded configuration from: %s\n", p.agentDefinitionPath) + } fmt.Fprintf(os.Stderr, "Using endpoint: %s\n", azdEnv["FOUNDRY_PROJECT_ENDPOINT"]) fmt.Fprintf(os.Stderr, "Agent Name: %s\n", agentDef.Name) @@ -1249,8 +1224,8 @@ func (p *AgentServiceTargetProvider) prepareDeploy( } // Parse service config for container resource overrides - var foundryAgentConfig *ServiceTargetAgentConfig - if err := UnmarshalStruct(serviceConfig.Config, &foundryAgentConfig); err != nil { + foundryAgentConfig, err := LoadServiceTargetAgentConfig(serviceConfig) + if err != nil { return nil, exterrors.Validation( exterrors.CodeInvalidAgentManifest, fmt.Sprintf("failed to parse foundry agent config: %s", err), @@ -1258,7 +1233,7 @@ func (p *AgentServiceTargetProvider) prepareDeploy( ) } - warnDeprecatedScaleSettings(serviceConfig.Config) + warnDeprecatedScaleSettings(ServiceConfigProps(serviceConfig)) var cpu, memory string if foundryAgentConfig != nil && foundryAgentConfig.Container != nil && foundryAgentConfig.Container.Resources != nil { @@ -1470,28 +1445,30 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( // packageCodeDeploy creates a ZIP archive of the agent source code, writes it to a temp file, // and computes its SHA-256. Returns the temp file path and SHA-256 hex string. func (p *AgentServiceTargetProvider) packageCodeDeploy(ctx context.Context, serviceConfig *azdext.ServiceConfig) (string, string, error) { - // Source directory is the service's relative path - srcDir := filepath.Dir(p.agentDefinitionPath) - - // Load agent.yaml to check runtime and dependency resolution for dotnet bundled mode - if data, err := os.ReadFile(p.agentDefinitionPath); err == nil { //nolint:gosec // path from internal state - var agentDef agent_yaml.ContainerAgent - if err := yaml.Unmarshal(data, &agentDef); err == nil && agentDef.CodeConfiguration != nil { - isDotnet := strings.HasPrefix(agentDef.CodeConfiguration.Runtime, "dotnet_") - isBundled := false // default is remote_build (matches promptCodeConfig and deployHostedCodeAgent defaults) - if agentDef.CodeConfiguration.DependencyResolution != nil { - isBundled = *agentDef.CodeConfiguration.DependencyResolution == "bundled" - } - if isDotnet && isBundled { - return p.packageDotnetBundled(srcDir) - } + // Source directory is the service's directory. Fall back to the directory of + // a legacy on-disk agent.yaml when the service path was not resolved. + srcDir := p.servicePath + if srcDir == "" { + srcDir = filepath.Dir(p.agentDefinitionPath) + } + + // Check runtime and dependency resolution for dotnet bundled mode + if agentDef, isHosted, err := p.loadContainerAgentDefinition(); err == nil && isHosted && + agentDef.CodeConfiguration != nil { + isDotnet := strings.HasPrefix(agentDef.CodeConfiguration.Runtime, "dotnet_") + isBundled := false // default is remote_build (matches promptCodeConfig and deployHostedCodeAgent defaults) + if agentDef.CodeConfiguration.DependencyResolution != nil { + isBundled = *agentDef.CodeConfiguration.DependencyResolution == "bundled" + } + if isDotnet && isBundled { + return p.packageDotnetBundled(srcDir) + } - // Python bundled: validate that dependencies are installed in srcDir - isPython := strings.HasPrefix(agentDef.CodeConfiguration.Runtime, "python_") - if isPython && isBundled { - if err := validatePythonBundledDeps(srcDir); err != nil { - return "", "", err - } + // Python bundled: validate that dependencies are installed in srcDir + isPython := strings.HasPrefix(agentDef.CodeConfiguration.Runtime, "python_") + if isPython && isBundled { + if err := validatePythonBundledDeps(srcDir); err != nil { + return "", "", err } } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index 9e321b9aa54..3a665c44c5a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -987,6 +987,34 @@ func TestLoadContainerAgentDefinition_MalformedYAMLReturnsError(t *testing.T) { require.Contains(t, err.Error(), "agent.yaml is not valid") } +func TestLoadContainerAgentDefinition_EnvPathOverridesInlineDefinition(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + agentPath := filepath.Join(dir, "agent.yaml") + require.NoError(t, os.WriteFile( + agentPath, + []byte("kind: hosted\nname: override-agent\nprotocols:\n - protocol: responses\n version: \"1.0.0\"\n"), + 0o600, + )) + + props, err := AgentDefinitionToServiceProperties(sampleContainerAgent(), nil) + require.NoError(t, err) + provider := &AgentServiceTargetProvider{ + agentDefinitionPath: agentPath, + serviceConfig: &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + }, + } + + got, isHosted, err := provider.loadContainerAgentDefinition() + require.NoError(t, err) + require.True(t, isHosted) + require.Equal(t, "override-agent", got.Name) +} + func TestShouldUsePreBuiltImage_NoImageDefaultsToBuild(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_resource.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_resource.go deleted file mode 100644 index 523984acf9d..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_resource.go +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package project - -import ( - "context" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" -) - -var _ azdext.ServiceTargetProvider = (*ResourceServiceTargetProvider)(nil) - -// ResourceServiceTargetProvider is a no-op service target shared by the Foundry -// resource hosts that `azd ai agent init` writes as sibling service entries: -// azure.ai.project, azure.ai.connection, and azure.ai.toolbox. The agents -// extension registers all three so `azd up`/`azd deploy` can walk the service -// entries the agent references via uses:, without requiring a separate -// extension per host. The resources themselves are created by Bicep during -// `azd provision` (orchestrated by this extension), so every deploy-time hook -// here intentionally does nothing. -// -// These hosts share one provider type because none of them has deploy-time -// behavior yet. When a host gains real backend functionality it can move to its -// own dedicated extension, at which point that extension registers the host -// instead of this one. -type ResourceServiceTargetProvider struct { - azdClient *azdext.AzdClient - serviceConfig *azdext.ServiceConfig -} - -// NewResourceServiceTargetProvider creates a no-op service target for a Foundry -// resource host. -func NewResourceServiceTargetProvider(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { - return &ResourceServiceTargetProvider{azdClient: azdClient} -} - -// Initialize stores the service configuration; no other setup is required. -func (p *ResourceServiceTargetProvider) Initialize( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, -) error { - p.serviceConfig = serviceConfig - return nil -} - -// Endpoints returns no endpoints; Foundry resource services do not expose any. -func (p *ResourceServiceTargetProvider) Endpoints( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - targetResource *azdext.TargetResource, -) ([]string, error) { - return nil, nil -} - -// GetTargetResource resolves the target resource. It delegates to azd's default -// resolver and falls back to a minimal target so the deploy pipeline can proceed. -func (p *ResourceServiceTargetProvider) GetTargetResource( - ctx context.Context, - subscriptionId string, - serviceConfig *azdext.ServiceConfig, - defaultResolver func() (*azdext.TargetResource, error), -) (*azdext.TargetResource, error) { - if defaultResolver != nil { - if target, err := defaultResolver(); err == nil && target != nil { - return target, nil - } - } - - // Deploy is a no-op and does not use the target; azd only requires a - // non-nil target to continue the deploy pipeline. - return &azdext.TargetResource{SubscriptionId: subscriptionId}, nil -} - -// Package is a no-op; there is nothing to build or stage for a resource service. -func (p *ResourceServiceTargetProvider) Package( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - serviceContext *azdext.ServiceContext, - progress azdext.ProgressReporter, -) (*azdext.ServicePackageResult, error) { - return &azdext.ServicePackageResult{}, nil -} - -// Publish is a no-op; resource services have no artifacts to publish. -func (p *ResourceServiceTargetProvider) Publish( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - serviceContext *azdext.ServiceContext, - targetResource *azdext.TargetResource, - publishOptions *azdext.PublishOptions, - progress azdext.ProgressReporter, -) (*azdext.ServicePublishResult, error) { - return &azdext.ServicePublishResult{}, nil -} - -// Deploy is a no-op; the resources are created at provision time by Bicep. -func (p *ResourceServiceTargetProvider) Deploy( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - serviceContext *azdext.ServiceContext, - targetResource *azdext.TargetResource, - progress azdext.ProgressReporter, -) (*azdext.ServiceDeployResult, error) { - return &azdext.ServiceDeployResult{}, nil -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/templating.go b/cli/azd/extensions/azure.ai.agents/internal/project/templating.go index 031d94d3f46..9eeacd6774b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/templating.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/templating.go @@ -3,67 +3,12 @@ package project -import ( - "fmt" - "regexp" - "strings" +import "github.com/azure/azure-dev/cli/azd/pkg/foundry" - "github.com/drone/envsubst" -) - -// foundryTemplatePattern matches Foundry server-side ${{...}} expressions. These are resolved -// by Foundry at runtime (for example ${{connections.x.credentials.key}} or ${{event.body}}) -// and must survive azd's client-side ${VAR} expansion untouched. The (?s) flag lets the span -// cross newlines; the lazy quantifier stops at the first closing }}. -var foundryTemplatePattern = regexp.MustCompile(`(?s)\$\{\{.*?\}\}`) - -// foundrySentinelBase is the prefix for placeholders that temporarily replace ${{...}} spans -// while ${VAR} expansion runs. It contains no '$', '{', or '}', so drone/envsubst (which only -// expands the braced ${...} form) copies it through untouched, even when a literal '$' precedes -// it. -const foundrySentinelBase = "azdFoundryTemplateSpan_" - -// ExpandEnv expands ${VAR} references in value against the azd environment (via mapping) while -// preserving Foundry server-side ${{...}} expressions verbatim. It supports default values -// (${VAR:-default}) and multiple expressions, matching drone/envsubst semantics for the ${VAR} -// portion. On expansion error the original value is returned unchanged alongside the error. -// -// This is the single shared expander every Foundry field should route through so ${VAR} and -// ${{...}} are handled consistently. drone/envsubst cannot parse ${{...}}, so each span is -// masked with a sentinel placeholder, a single Eval expands the ${VAR} references, then the -// spans are restored. Masking rather than splitting preserves full ${VAR:-default} semantics -// even when a ${{...}} expression is the default value (e.g. ${MISSING:-${{event.body}}}). -// A ${VAR} inside a ${{...}} span is left as-is, since the span is reserved for Foundry. +// ExpandEnv re-exports the shared Foundry expander so every Foundry field in this +// extension expands ${VAR} (against the azd environment) while preserving Foundry +// server-side ${{...}} expressions verbatim. The implementation is shared across +// the Foundry extensions in [foundry.ExpandEnv]. func ExpandEnv(value string, mapping func(string) string) (string, error) { - spans := foundryTemplatePattern.FindAllString(value, -1) - if len(spans) == 0 { - expanded, err := envsubst.Eval(value, mapping) - if err != nil { - return value, err - } - return expanded, nil - } - - // Choose a sentinel that does not already occur in the input so restoration is exact. - sentinel := foundrySentinelBase - for strings.Contains(value, sentinel) { - sentinel += "_" - } - - index := 0 - masked := foundryTemplatePattern.ReplaceAllStringFunc(value, func(string) string { - placeholder := fmt.Sprintf("%s%d_", sentinel, index) - index++ - return placeholder - }) - - expanded, err := envsubst.Eval(masked, mapping) - if err != nil { - return value, err - } - - for i, span := range spans { - expanded = strings.Replace(expanded, fmt.Sprintf("%s%d_", sentinel, i), span, 1) - } - return expanded, nil + return foundry.ExpandEnv(value, mapping) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go index 789dc51a1f5..387e8bb793b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -189,7 +189,7 @@ func Synthesize(in Input) (*Result, error) { if len(in.AcceptedHosts) > 0 && !slices.Contains(in.AcceptedHosts, svc.Host) { return nil, ErrServiceNotFound } - if svc.Endpoint != "" { + if strings.TrimSpace(svc.Endpoint) != "" { return nil, ErrEndpointBrownfield } diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go index 379083fb060..4fac8bec6bb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go @@ -211,6 +211,16 @@ services: serviceName: "my-project", wantErr: ErrEndpointBrownfield, }, + { + name: "blank endpoint is treated as greenfield", + yaml: ` +services: + my-project: + host: azure.ai.agent + endpoint: " " +`, + serviceName: "my-project", + }, { name: "service not found", yaml: ` diff --git a/cli/azd/extensions/azure.ai.agents/schemas/Agent.json b/cli/azd/extensions/azure.ai.agents/schemas/Agent.json index c7026d9aab9..b9881b7850e 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/Agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/Agent.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.agents/schemas/Agent.json", + "$id": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/Agent.json", "title": "Foundry agent definition", "description": "Hosted or prompt agent. Items in an agents array may be inline (hosted or prompt) or a $ref to an external file.", "oneOf": [ diff --git a/cli/azd/extensions/azure.ai.agents/schemas/Connection.json b/cli/azd/extensions/azure.ai.agents/schemas/Connection.json index ef9ddd8482f..ba9c65e42ad 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/Connection.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/Connection.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.agents/schemas/Connection.json", + "$id": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/Connection.json", "title": "Foundry project connection", "description": "A project connection (matches the existing azure.ai.agent.json Connection shape). Items in a connections array may be inline or a $ref to an external file.", "oneOf": [ diff --git a/cli/azd/extensions/azure.ai.agents/schemas/Deployment.json b/cli/azd/extensions/azure.ai.agents/schemas/Deployment.json index 7f031bc0482..de370c7ea5c 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/Deployment.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/Deployment.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.agents/schemas/Deployment.json", + "$id": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/Deployment.json", "title": "Foundry model deployment", "description": "A model deployment on the Foundry project. Matches the existing azure.ai.agent.json shape (name, model, sku). Items in a deployments array may be inline or a $ref to an external file.", "oneOf": [ diff --git a/cli/azd/extensions/azure.ai.agents/schemas/FileRef.json b/cli/azd/extensions/azure.ai.agents/schemas/FileRef.json index b62f13ed1d1..b004897250b 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/FileRef.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/FileRef.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.agents/schemas/FileRef.json", + "$id": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/FileRef.json", "title": "External file reference", "description": "Replace an inline definition with a reference to an external YAML or JSON file. Sibling properties on the same object act as overlay overrides on top of the loaded file.", "type": "object", @@ -8,7 +8,7 @@ "properties": { "$ref": { "type": "string", - "description": "Path to a YAML or JSON file containing the definition. Relative paths resolve from the file containing this $ref. Absolute paths and URLs are also accepted." + "description": "Path to a YAML or JSON file containing the definition. Relative paths resolve from the file containing this $ref. Absolute paths are also accepted; remote URLs are not supported." } }, "additionalProperties": true diff --git a/cli/azd/extensions/azure.ai.agents/schemas/README.md b/cli/azd/extensions/azure.ai.agents/schemas/README.md index b04d4e44654..ccb23b93717 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/README.md +++ b/cli/azd/extensions/azure.ai.agents/schemas/README.md @@ -1,100 +1,60 @@ -# Foundry `azure.yaml` schemas (preview — integration branch) - -These schemas model `host: microsoft.foundry` in `azure.yaml`. They are **not on `main`**. -They live on the long-lived integration branch **`huimiu/foundry-azure-yaml`** so the feature can -be prototyped and tested without being published to every `azd` user before it is ready. - -## Why the URLs point at the branch - -The core `azure.yaml` schema is consumed *live* from `main`: - -- `schemas/schemastore-catalog-entry.json` registers it with **SchemaStore** at the `main` raw URL. -- `azd` stamps `# yaml-language-server: $schema=…/main/schemas//azure.yaml.json` into generated - `azure.yaml` files. - -So merging `microsoft.foundry` into `main` would immediately surface an unfinished feature in every -editor. To test on the branch instead, the **new Foundry** schema URLs are rewritten from the `main` -raw URL to the `huimiu/foundry-azure-yaml` branch. All schema URLs use the short -`raw.githubusercontent.com/Azure/azure-dev//…` form (no `refs/heads/` segment), matching the -core schema's own root `$id` and the SchemaStore retrieval URL: - -- the `$id` of every file in this directory (`Agent.json`, `Skill.json`, `Routine.json`, - `Connection.json`, `Toolbox.json`, `Deployment.json`, `FileRef.json`, `microsoft.foundry.json`); -- the `microsoft.foundry.json` `$ref` added to `schemas/v1.0/azure.yaml.json` and - `schemas/alpha/azure.yaml.json`; -- the `$schema` annotation in `examples/*.azure.yaml`. - -Relative `$ref`s (for example `"FileRef.json"`) resolve against the parent's `$id`, so they follow -the branch automatically. - -**Deliberately left pointing at `main`** (published / shared surfaces, unchanged by this work): - -- the core schema's own root `$id`; -- the pre-existing `azure.ai.agent.json` `$ref` in the core schema (still `main`; only its URL form - was normalized to the short form for consistency); -- `schemas/schemastore-catalog-entry.json`; -- `cli/azd/pkg/project/project.go` and `resources/apphost/templates/azure.yamlt`. - -## How to test on the branch - -1. Push the integration branch to `origin` (the raw URLs only resolve once the branch is published). -2. **In an editor (primary):** open `examples/simple.azure.yaml` or `examples/complex.azure.yaml`. - The `$schema` comment makes the YAML language server validate the file against the branch schema, - resolving the composed Foundry sub-schemas over the branch raw URLs. -3. **CLI (optional, offline):** validate the samples with `ajv`, loading the local schema files by - their `$id` so no network fetch is needed: - - ```bash - # from the repo root, in a scratch dir - npm init -y && npm install ajv@8 ajv-formats js-yaml glob - node - <<'EOF' - const fs = require('fs'); - const path = require('path'); - const yaml = require('js-yaml'); - const Ajv = require('ajv/dist/2019').default; - const addFormats = require('ajv-formats').default; - const { globSync } = require('glob'); - - const repo = process.env.REPO || '.'; - const ajv = new Ajv({ allErrors: true, strict: false }); - addFormats(ajv); - ajv.addMetaSchema(require('ajv/dist/refs/json-schema-draft-07.json')); - - // Register every schema under its own $id (core + Foundry sub-schemas + existing agent schema). - // The pre-existing azure.ai.agent.json has no $id, so register it under the `main` raw URL the - // core schema references. - const core = path.join(repo, 'schemas/v1.0/azure.yaml.json'); - const subs = globSync(path.join(repo, 'cli/azd/extensions/azure.ai.agents/schemas/*.json')); - for (const f of [core, ...subs]) { - const s = JSON.parse(fs.readFileSync(f, 'utf8')); - const rel = path.relative(repo, f).replace(/\\/g, '/'); - const id = s.$id || `https://raw.githubusercontent.com/Azure/azure-dev/main/${rel}`; - ajv.addSchema(s, id); - } - - const validate = ajv.getSchema(JSON.parse(fs.readFileSync(core, 'utf8')).$id); - for (const s of globSync(path.join(repo, - 'cli/azd/extensions/azure.ai.agents/schemas/examples/*.azure.yaml'))) { - const ok = validate(yaml.load(fs.readFileSync(s, 'utf8'))); - console.log(`${ok ? 'PASS' : 'FAIL'} ${path.basename(s)}`); - if (!ok) { console.error(validate.errors); process.exitCode = 1; } - } - EOF - ``` - - The example file-ref targets (`./agents/triage.yaml`, etc.) are illustrative; they are validated as - `FileRef` shapes, so the referenced files do not need to exist for schema validation. - -## Before merging to `main` — flip-back checklist (required) - -When the prototype is ready and the integration branch is merged into `main`, rewrite the branch URLs -back so the published schema is self-consistent on `main`: - -- [ ] In all files in this directory, change `$id` `huimiu/foundry-azure-yaml` → `main`. -- [ ] In `schemas/v1.0/azure.yaml.json` and `schemas/alpha/azure.yaml.json`, change the - `microsoft.foundry.json` `$ref` `huimiu/foundry-azure-yaml` → `main`. -- [ ] In `examples/*.azure.yaml`, change the `$schema` annotation - `huimiu/foundry-azure-yaml` → `main`. -- [ ] Confirm no `huimiu/foundry-azure-yaml` references remain: - `git grep -n "huimiu/foundry-azure-yaml"` returns nothing. -- [ ] Re-validate the samples (steps above) against the `main` URLs. +# Foundry `azure.yaml` schemas + +These schemas describe Foundry resource shapes used by `azure.yaml`. + +The root `azure.yaml` schemas in `schemas/v1.0/azure.yaml.json` and +`schemas/alpha/azure.yaml.json` are consumed from the repository's `main` raw +URLs through SchemaStore and generated `# yaml-language-server: $schema=...` +comments. Keep the `$id` values and example `$schema` annotations in this +directory on `main` as well, so editor validators can resolve composed schemas +after release. + +Relative `$ref`s, for example `"FileRef.json"`, resolve against the parent +schema's `$id`. + +## Local validation + +Validate the examples with `ajv`, loading local schema files by their `$id` so no +network fetch is needed: + +```bash +# from the repo root, in a scratch dir +npm init -y && npm install ajv@8 ajv-formats js-yaml glob +node - <<'EOF' +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const Ajv = require('ajv/dist/2019').default; +const addFormats = require('ajv-formats').default; +const { globSync } = require('glob'); + +const repo = process.env.REPO || '.'; +const ajv = new Ajv({ allErrors: true, strict: false }); +addFormats(ajv); +ajv.addMetaSchema(require('ajv/dist/refs/json-schema-draft-07.json')); + +const core = path.join(repo, 'schemas/v1.0/azure.yaml.json'); +// Register every Foundry extension's schemas (agents, projects, connections, +// toolboxes, skills, routines) so ajv can resolve the cross-extension $refs that +// schemas/v1.0/azure.yaml.json composes, keeping validation fully offline. +const subs = globSync(path.join(repo, 'cli/azd/extensions/azure.ai.*/schemas/*.json')); +for (const f of [core, ...subs]) { + const s = JSON.parse(fs.readFileSync(f, 'utf8')); + const rel = path.relative(repo, f).replace(/\\/g, '/'); + const id = s.$id || `https://raw.githubusercontent.com/Azure/azure-dev/main/${rel}`; + ajv.addSchema(s, id); +} + +const validate = ajv.getSchema(JSON.parse(fs.readFileSync(core, 'utf8')).$id); +for (const s of globSync(path.join(repo, + 'cli/azd/extensions/azure.ai.agents/schemas/examples/*.azure.yaml'))) { + const ok = validate(yaml.load(fs.readFileSync(s, 'utf8'))); + console.log(`${ok ? 'PASS' : 'FAIL'} ${path.basename(s)}`); + if (!ok) { console.error(validate.errors); process.exitCode = 1; } +} +EOF +``` + +The example file-ref targets such as `./agents/triage.yaml` are illustrative; +they are validated as `FileRef` shapes, so the referenced files do not need to +exist for schema validation. diff --git a/cli/azd/extensions/azure.ai.agents/schemas/Routine.json b/cli/azd/extensions/azure.ai.agents/schemas/Routine.json index 886b2034e0c..c5559a3cb2a 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/Routine.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/Routine.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.agents/schemas/Routine.json", + "$id": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/Routine.json", "title": "Foundry routine (scheduled or event-driven agent invocation)", "description": "Items in a routines array may be inline or a $ref to an external file.", "oneOf": [ diff --git a/cli/azd/extensions/azure.ai.agents/schemas/Skill.json b/cli/azd/extensions/azure.ai.agents/schemas/Skill.json index f7fafd531f1..02b77b20c9b 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/Skill.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/Skill.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.agents/schemas/Skill.json", + "$id": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/Skill.json", "title": "Foundry skill", "description": "A reusable capability bundle (instructions + tools). Items in a skills array may be inline or a $ref to an external file.", "oneOf": [ diff --git a/cli/azd/extensions/azure.ai.agents/schemas/Toolbox.json b/cli/azd/extensions/azure.ai.agents/schemas/Toolbox.json index 1297fb9f7e0..5f08b37d4cb 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/Toolbox.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/Toolbox.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.agents/schemas/Toolbox.json", + "$id": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/Toolbox.json", "title": "Foundry toolbox", "description": "A named bundle of tools that agents reference by name (matches the existing azure.ai.agent.json Toolbox shape). Items in a toolboxes array may be inline or a $ref to an external file.", "oneOf": [ diff --git a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json index 312b415bc6e..797a7ace006 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json @@ -42,10 +42,96 @@ "startupCommand": { "type": "string", "description": "Command to start the agent server (e.g., 'python main.py'). Used by 'azd ai agent run' for local development." + }, + "kind": { + "type": "string", + "description": "The agent kind. Currently only 'hosted' is supported.", + "enum": ["hosted"] + }, + "name": { + "type": "string", + "description": "The agent name." + }, + "displayName": { + "type": "string", + "description": "Optional human-friendly display name for the agent." + }, + "description": { + "type": "string", + "description": "Optional description of the agent." + }, + "metadata": { + "type": "object", + "description": "Optional metadata key-value pairs for the agent.", + "additionalProperties": true + }, + "protocols": { + "type": "array", + "description": "Invocation protocols the agent implements (e.g., responses, invocations, a2a).", + "items": { "$ref": "#/definitions/ProtocolVersionRecord" } + }, + "agentEndpoint": { + "type": "object", + "description": "Agent endpoint configuration (protocols, version selection, auth).", + "additionalProperties": true + }, + "agentCard": { + "type": "object", + "description": "A2A discovery metadata for the agent.", + "additionalProperties": true + }, + "codeConfiguration": { + "$ref": "#/definitions/CodeConfiguration" + }, + "policies": { + "type": "array", + "description": "Governance policies attached to the agent (e.g., Responsible AI).", + "items": { "$ref": "#/definitions/Policy" } + }, + "inputSchema": { + "type": "object", + "description": "Optional input schema for the agent.", + "additionalProperties": true + }, + "outputSchema": { + "type": "object", + "description": "Optional output schema for the agent.", + "additionalProperties": true } }, - "additionalProperties": false, + "additionalProperties": true, "definitions": { + "ProtocolVersionRecord": { + "type": "object", + "description": "A protocol the agent implements, with its version.", + "properties": { + "protocol": { "type": "string", "description": "Protocol name (e.g., 'responses', 'invocations', 'a2a')." }, + "version": { "type": "string", "description": "Protocol version." } + }, + "required": ["protocol"], + "additionalProperties": false + }, + "CodeConfiguration": { + "type": "object", + "description": "Code deploy configuration. When present, the agent is deployed from source (ZIP) instead of a container image.", + "properties": { + "runtime": { "type": "string", "description": "Runtime identifier (e.g., 'python_3_12', 'dotnet_9')." }, + "entryPoint": { "type": "string", "description": "Entry point for the agent source." }, + "dependencyResolution": { "type": "string", "description": "Dependency resolution mode (e.g., 'bundled', 'remote_build')." } + }, + "required": ["runtime", "entryPoint"], + "additionalProperties": false + }, + "Policy": { + "type": "object", + "description": "A safety or governance policy attached to the agent.", + "properties": { + "type": { "type": "string", "description": "Policy type (e.g., 'rai_policy')." }, + "raiPolicyName": { "type": "string", "description": "ARM resource ID of the RAI policy (for type 'rai_policy')." } + }, + "required": ["type"], + "additionalProperties": false + }, "ContainerSettings": { "type": "object", "description": "Container configuration for the Azure AI Agent Service target", diff --git a/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml b/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml index 89ad8b2380d..5e86330f023 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml +++ b/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/schemas/v1.0/azure.yaml.json +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json # Exercises the full microsoft.foundry surface: deployments, connections, # toolboxes, skills, routines, hosted + prompt agents, inline tools, and diff --git a/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml b/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml index cf80f00eac7..d834134b42e 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml +++ b/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/schemas/v1.0/azure.yaml.json +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json # Minimal microsoft.foundry service: one model deployment and one prompt agent. # Schema-validation fixture for the integration branch (see ../README.md). diff --git a/cli/azd/extensions/azure.ai.connections/CHANGELOG.md b/cli/azd/extensions/azure.ai.connections/CHANGELOG.md index 3c77b09ab96..1a34b651763 100644 --- a/cli/azd/extensions/azure.ai.connections/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.connections/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features Added + +- The `azure.ai.connections` extension now registers an `azure.ai.connection` service-target host. `azd deploy`/`azd up` upsert each `host: azure.ai.connection` service in `azure.yaml` onto the Foundry project with an idempotent ARM CreateOrUpdate, expanding `${VAR}` secrets from the azd environment while passing Foundry server-side `${{...}}` expressions through untouched. + ## 0.1.2-preview (2026-06-19) ### Bugs Fixed diff --git a/cli/azd/extensions/azure.ai.connections/extension.yaml b/cli/azd/extensions/azure.ai.connections/extension.yaml index 0a2c0f2382c..36ae49c7b3a 100644 --- a/cli/azd/extensions/azure.ai.connections/extension.yaml +++ b/cli/azd/extensions/azure.ai.connections/extension.yaml @@ -1,14 +1,19 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/extension.schema.json capabilities: - custom-commands + - service-target-provider - metadata description: Manage Microsoft Foundry Connections from your terminal. (Preview) displayName: Foundry Connections (Preview) id: azure.ai.connections language: go namespace: ai.connection +providers: + - name: azure.ai.connection + type: service-target + description: Upserts Foundry connections declared as azure.ai.connection services in azure.yaml tags: - ai - connection usage: azd ai connection [options] -version: 0.1.2-preview +version: 0.1.3-preview diff --git a/cli/azd/extensions/azure.ai.connections/go.mod b/cli/azd/extensions/azure.ai.connections/go.mod index 005f3eb9dc1..5b0958a2ee5 100644 --- a/cli/azd/extensions/azure.ai.connections/go.mod +++ b/cli/azd/extensions/azure.ai.connections/go.mod @@ -2,6 +2,11 @@ module azure.ai.connections go 1.26.4 +// TEMPORARY: local validation against the in-tree azd core for the shared +// pkg/foundry helpers (the $ref resolver and ${VAR}/${{...}} expander). Remove +// before merging — the core change must land first, then bump the azd dependency. +replace github.com/azure/azure-dev/cli/azd => ../../ + require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 @@ -14,6 +19,7 @@ require ( ) require ( + dario.cat/mergo v1.0.2 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 // indirect @@ -91,12 +97,14 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.49.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/cli/azd/extensions/azure.ai.connections/go.sum b/cli/azd/extensions/azure.ai.connections/go.sum index b53678577db..63edde352c2 100644 --- a/cli/azd/extensions/azure.ai.connections/go.sum +++ b/cli/azd/extensions/azure.ai.connections/go.sum @@ -1,4 +1,6 @@ code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= @@ -49,8 +51,6 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/azure/azure-dev/cli/azd v1.25.0 h1:gb8Ah5ntUcUAKIDBhCdpx8xxDWSAtCLGyck+Y50QZhw= -github.com/azure/azure-dev/cli/azd v1.25.0/go.mod h1:1ZoZZlUbK8FMTZRibM9hEo/UqSaEXA+SFeIKpya4fsY= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= @@ -249,10 +249,12 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -260,11 +262,13 @@ golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -276,18 +280,18 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/root.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/root.go index fd5c9c8ea2d..bc9c62e4579 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/root.go @@ -26,6 +26,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newContextCommand()) rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) rootCmd.AddCommand(newMetadataCommand(rootCmd)) + rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) // Register -p / --project-endpoint as a persistent flag inherited by // connection CRUD subcommands (list, show, create, update, delete). @@ -41,3 +42,13 @@ func NewRootCommand() *cobra.Command { return rootCmd } + +// configureExtensionHost is the listen callback. It registers the +// azure.ai.connection service target so `azd up`/`azd deploy` upsert connections +// declared as services in azure.yaml. +func configureExtensionHost(host *azdext.ExtensionHost) { + azdClient := host.Client() + host.WithServiceTarget(aiConnectionHost, func() azdext.ServiceTargetProvider { + return newConnectionServiceTarget(azdClient) + }) +} diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go new file mode 100644 index 00000000000..6e26b80325c --- /dev/null +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go @@ -0,0 +1,273 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "azure.ai.connections/internal/exterrors" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" +) + +// aiConnectionHost is the azure.yaml service host kind owned by this extension. A +// `host: azure.ai.connection` service entry carries one Foundry project connection, +// keyed by the connection name, and is reconciled (upserted) at deploy time by +// connectionServiceTarget instead of being layered into provisioning. +const aiConnectionHost = "azure.ai.connection" + +var _ azdext.ServiceTargetProvider = (*connectionServiceTarget)(nil) + +// connectionServiceConfig is the service-level shape of a `host: azure.ai.connection` +// entry (see schemas/azure.ai.connection.json). The connection name is the azure.yaml +// service key, not a body field. +type connectionServiceConfig struct { + Category string `json:"category,omitempty"` + Target string `json:"target,omitempty"` + AuthType string `json:"authType,omitempty"` + Credentials map[string]any `json:"credentials,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// connectionServiceTarget upserts a Foundry connection declared as an +// azure.ai.connection service. Deploy issues an idempotent ARM CreateOrUpdate on the +// project's connection; the resource name is the service key. Package and Publish are +// no-ops because a connection has no build artifact. +type connectionServiceTarget struct { + azdClient *azdext.AzdClient + serviceConfig *azdext.ServiceConfig +} + +// newConnectionServiceTarget creates the azure.ai.connection service-target provider. +func newConnectionServiceTarget(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { + return &connectionServiceTarget{azdClient: azdClient} +} + +// Initialize stores the service configuration; no other setup is required. +func (p *connectionServiceTarget) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { + p.serviceConfig = serviceConfig + return nil +} + +// Endpoints returns no endpoints; a connection service exposes none. +func (p *connectionServiceTarget) Endpoints( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + targetResource *azdext.TargetResource, +) ([]string, error) { + return nil, nil +} + +// GetTargetResource delegates to azd's default resolver and falls back to a minimal +// target so the deploy pipeline can proceed; the connection upsert targets the Foundry +// project, not an ARM resource azd tracks. +func (p *connectionServiceTarget) GetTargetResource( + ctx context.Context, + subscriptionId string, + serviceConfig *azdext.ServiceConfig, + defaultResolver func() (*azdext.TargetResource, error), +) (*azdext.TargetResource, error) { + if defaultResolver != nil { + if target, err := defaultResolver(); err == nil && target != nil { + return target, nil + } + } + return &azdext.TargetResource{SubscriptionId: subscriptionId}, nil +} + +// Package is a no-op; a connection has nothing to build or stage. +func (p *connectionServiceTarget) Package( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + progress azdext.ProgressReporter, +) (*azdext.ServicePackageResult, error) { + return &azdext.ServicePackageResult{}, nil +} + +// Publish is a no-op; a connection has no artifact to publish. +func (p *connectionServiceTarget) Publish( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + publishOptions *azdext.PublishOptions, + progress azdext.ProgressReporter, +) (*azdext.ServicePublishResult, error) { + return &azdext.ServicePublishResult{}, nil +} + +// Deploy upserts the connection on its project via an idempotent ARM CreateOrUpdate. +// ${VAR} references in the target and credential values resolve against the azd +// environment; Foundry server-side ${{...}} expressions pass through untouched. +// Removing the service from azure.yaml stops azd managing the connection but does not +// delete it (use `azd ai connection delete`). +func (p *connectionServiceTarget) Deploy( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + progress azdext.ProgressReporter, +) (*azdext.ServiceDeployResult, error) { + cfg, err := parseConnectionServiceConfig(serviceConfig) + if err != nil { + return nil, err + } + name := serviceConfig.GetName() + + env, err := p.currentEnvValues(ctx) + if err != nil { + return nil, err + } + expand := func(value string) string { return resolveConnectionEnv(value, env) } + + kebabAuth := normalizeAuthType(strings.TrimSpace(cfg.AuthType)) + key, customKeys := connectionCredentialArgs(kebabAuth, cfg.Credentials, expand) + body, err := buildConnectionBody( + cfg.Category, expand(cfg.Target), kebabAuth, key, customKeys, + connectionMetadataPairs(cfg.Metadata, expand), "", "", + ) + if err != nil { + return nil, err + } + + if progress != nil { + progress(fmt.Sprintf("Upserting connection %q", name)) + } + + connCtx, err := resolveConnectionContext(ctx, "") + if err != nil { + return nil, err + } + + if _, err := connCtx.armClient.Create( + ctx, connCtx.rg, connCtx.account, connCtx.project, name, + &armcognitiveservices.ProjectConnectionsClientCreateOptions{Connection: body}, + ); err != nil { + return nil, exterrors.ServiceFromAzure(err, "deploy connection") + } + + return &azdext.ServiceDeployResult{}, nil +} + +// parseConnectionServiceConfig reads the service-level (inline) connection properties, +// falling back to the deprecated config: shape for azure.yaml files written before the +// per-resource service split. +func parseConnectionServiceConfig(svc *azdext.ServiceConfig) (*connectionServiceConfig, error) { + props := svc.GetAdditionalProperties() + if props == nil || len(props.GetFields()) == 0 { + props = svc.GetConfig() + } + cfg := &connectionServiceConfig{} + if props == nil { + return cfg, nil + } + b, err := json.Marshal(props.AsMap()) + if err != nil { + return nil, fmt.Errorf("encoding connection service %q config: %w", svc.GetName(), err) + } + if err := json.Unmarshal(b, cfg); err != nil { + return nil, fmt.Errorf("parsing connection service %q config: %w", svc.GetName(), err) + } + return cfg, nil +} + +// currentEnvValues loads all key-value pairs from the active azd environment, used to +// resolve ${VAR} references in connection fields at deploy time. +func (p *connectionServiceTarget) currentEnvValues(ctx context.Context) (map[string]string, error) { + current, err := p.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, fmt.Errorf("resolving current azd environment: %w", err) + } + resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: current.GetEnvironment().GetName(), + }) + if err != nil { + return nil, fmt.Errorf("loading azd environment values: %w", err) + } + values := make(map[string]string, len(resp.GetKeyValues())) + for _, kv := range resp.GetKeyValues() { + values[kv.GetKey()] = kv.GetValue() + } + return values, nil +} + +// resolveConnectionEnv expands ${VAR} references against the azd environment while +// preserving Foundry server-side ${{...}} expressions. On expansion error the original +// value is returned unchanged. +func resolveConnectionEnv(value string, env map[string]string) string { + resolved, err := foundry.ExpandEnv(value, func(name string) string { return env[name] }) + if err != nil { + return value + } + return resolved +} + +// connectionCredentialArgs maps the service entry's credentials map to the key / +// custom-keys arguments buildConnectionBody expects, expanding ${VAR} per value. Only +// the auth types buildConnectionBody supports inline (api-key, custom-keys, none) are +// mapped here; other auth types surface buildConnectionBody's own validation error. +func connectionCredentialArgs( + kebabAuth string, + credentials map[string]any, + expand func(string) string, +) (key string, customKeys []string) { + switch kebabAuth { + case "api-key": + key = expand(stringFromAny(credentials["key"])) + case "custom-keys": + for _, k := range sortedKeys(credentials) { + customKeys = append(customKeys, fmt.Sprintf("%s=%s", k, expand(stringFromAny(credentials[k])))) + } + } + return key, customKeys +} + +// connectionMetadataPairs renders the metadata map as sorted key=value pairs with ${VAR} +// expanded, matching the []string shape buildConnectionBody consumes. +func connectionMetadataPairs(metadata map[string]string, expand func(string) string) []string { + if len(metadata) == 0 { + return nil + } + keys := make([]string, 0, len(metadata)) + for k := range metadata { + keys = append(keys, k) + } + sort.Strings(keys) + pairs := make([]string, 0, len(metadata)) + for _, k := range keys { + pairs = append(pairs, fmt.Sprintf("%s=%s", k, expand(metadata[k]))) + } + return pairs +} + +// sortedKeys returns the keys of m in sorted order so generated credential pairs are +// deterministic across deploys. +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// stringFromAny renders a credential or metadata value as a string. Non-string scalars +// are formatted with their default representation; nil becomes empty. +func stringFromAny(v any) string { + switch typed := v.(type) { + case nil: + return "" + case string: + return typed + default: + return fmt.Sprintf("%v", typed) + } +} diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go new file mode 100644 index 00000000000..60cf2cfacac --- /dev/null +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestParseConnectionServiceConfig_ServiceLevel(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "category": "ApiKey", + "target": "https://example.openai.azure.com", + "authType": "ApiKey", + "credentials": map[string]any{ + "key": "${SEARCH_KEY}", + }, + "metadata": map[string]any{"team": "search"}, + }) + require.NoError(t, err) + + cfg, err := parseConnectionServiceConfig(&azdext.ServiceConfig{ + Name: "prod-search", + Host: aiConnectionHost, + AdditionalProperties: props, + }) + require.NoError(t, err) + assert.Equal(t, "ApiKey", cfg.Category) + assert.Equal(t, "https://example.openai.azure.com", cfg.Target) + assert.Equal(t, "ApiKey", cfg.AuthType) + assert.Equal(t, "${SEARCH_KEY}", cfg.Credentials["key"]) + assert.Equal(t, "search", cfg.Metadata["team"]) +} + +// TestParseConnectionServiceConfig_ConfigFallback verifies connections written before the +// per-resource service split (config-nested shape) still parse. +func TestParseConnectionServiceConfig_ConfigFallback(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "category": "CustomKeys", + "authType": "CustomKeys", + }) + require.NoError(t, err) + + cfg, err := parseConnectionServiceConfig(&azdext.ServiceConfig{ + Name: "legacy", + Host: aiConnectionHost, + Config: props, + }) + require.NoError(t, err) + assert.Equal(t, "CustomKeys", cfg.Category) + assert.Equal(t, "CustomKeys", cfg.AuthType) +} + +func TestConnectionCredentialArgs(t *testing.T) { + t.Parallel() + + identity := func(s string) string { return s } + + t.Run("api-key reads the key credential", func(t *testing.T) { + t.Parallel() + key, customKeys := connectionCredentialArgs("api-key", map[string]any{"key": "secret"}, identity) + assert.Equal(t, "secret", key) + assert.Empty(t, customKeys) + }) + + t.Run("custom-keys renders sorted key=value pairs", func(t *testing.T) { + t.Parallel() + key, customKeys := connectionCredentialArgs( + "custom-keys", + map[string]any{"b-token": "2", "a-token": "1"}, + identity, + ) + assert.Empty(t, key) + assert.Equal(t, []string{"a-token=1", "b-token=2"}, customKeys) + }) +} + +func TestResolveConnectionEnv(t *testing.T) { + t.Parallel() + + env := map[string]string{"SEARCH_KEY": "resolved-secret"} + + // ${VAR} resolves from the azd env; Foundry ${{...}} passes through untouched. + assert.Equal(t, "resolved-secret", resolveConnectionEnv("${SEARCH_KEY}", env)) + assert.Equal(t, + "${{connections.x.credentials.key}}", + resolveConnectionEnv("${{connections.x.credentials.key}}", env), + ) +} + +func TestConnectionMetadataPairs(t *testing.T) { + t.Parallel() + + pairs := connectionMetadataPairs( + map[string]string{"type": "gateway", "team": "search"}, + func(s string) string { return s }, + ) + assert.Equal(t, []string{"team=search", "type=gateway"}, pairs) +} diff --git a/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json b/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json new file mode 100644 index 00000000000..f51ea03067e --- /dev/null +++ b/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json @@ -0,0 +1,50 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json", + "title": "Azure AI Foundry connection service", + "description": "Service-level configuration for a host: azure.ai.connection entry. The service key is the connection name. azd updates or creates the connection on the project the entry depends on.", + "type": "object", + "additionalProperties": true, + "properties": { + "category": { + "type": "string", + "description": "Connection category (e.g., 'CustomKeys', 'ApiKey', 'AzureOpenAI', 'CognitiveSearch', 'RemoteTool')." + }, + "target": { + "type": "string", + "description": "Target endpoint URL or ARM resource ID. May contain ${VAR} (azd env, resolved client-side)." + }, + "authType": { + "type": "string", + "description": "Authentication type.", + "enum": [ + "AAD", + "AccessKey", + "AccountKey", + "AgenticIdentity", + "AgenticIdentityToken", + "ApiKey", + "CustomKeys", + "ManagedIdentity", + "None", + "OAuth2", + "PAT", + "ProjectManagedIdentity", + "SAS", + "ServicePrincipal", + "UserEntraToken", + "UsernamePassword" + ] + }, + "credentials": { + "type": "object", + "description": "Credentials. Values may contain ${VAR} (azd env, resolved client-side) or ${{...}} (Foundry server-side resolution, passed through untouched).", + "additionalProperties": true + }, + "metadata": { + "type": "object", + "description": "Additional metadata as key-value pairs.", + "additionalProperties": { "type": "string" } + } + } +} diff --git a/cli/azd/extensions/azure.ai.connections/version.txt b/cli/azd/extensions/azure.ai.connections/version.txt index 15b416cc5ea..092cdfd781a 100644 --- a/cli/azd/extensions/azure.ai.connections/version.txt +++ b/cli/azd/extensions/azure.ai.connections/version.txt @@ -1 +1 @@ -0.1.2-preview +0.1.3-preview \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md index 6c6875ab6dd..e6e68954c07 100644 --- a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## Unreleased + +### Features Added + +- The `azure.ai.projects` extension now registers the `azure.ai.project` service-target host so `azd deploy`/`azd up` can walk the Foundry project service in `azure.yaml`. The project and its model deployments are provisioned by the built-in `microsoft.foundry` Bicep provider, so the deploy step is a no-op that owns the host. + ## 0.1.0-preview (2026-05-28) Initial preview release of the Foundry Projects extension. diff --git a/cli/azd/extensions/azure.ai.projects/extension.yaml b/cli/azd/extensions/azure.ai.projects/extension.yaml index 58914fd54c5..603a4b0cef2 100644 --- a/cli/azd/extensions/azure.ai.projects/extension.yaml +++ b/cli/azd/extensions/azure.ai.projects/extension.yaml @@ -1,14 +1,19 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/extension.schema.json capabilities: - custom-commands + - service-target-provider - metadata description: Manage Microsoft Foundry Project resources from your terminal. (Preview) displayName: Foundry Projects (Preview) id: azure.ai.projects language: go namespace: ai.project +providers: + - name: azure.ai.project + type: service-target + description: Owns the azure.ai.project host so azd can walk the Foundry project service in azure.yaml tags: - ai - project usage: azd ai project [options] -version: 0.1.0-preview +version: 0.1.1-preview diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go index faa218b61c0..822e7e60f17 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go @@ -29,6 +29,18 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newProjectSetCommand(extCtx)) rootCmd.AddCommand(newProjectUnsetCommand(extCtx)) rootCmd.AddCommand(newProjectShowCommand(extCtx)) + rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) return rootCmd } + +// configureExtensionHost is the listen callback. It registers the +// azure.ai.project service target so `azd up`/`azd deploy` can walk the project +// service declared in azure.yaml. The project itself is provisioned by the +// built-in microsoft.foundry Bicep provider, so the target is a no-op at deploy. +func configureExtensionHost(host *azdext.ExtensionHost) { + azdClient := host.Client() + host.WithServiceTarget(aiProjectHost, func() azdext.ServiceTargetProvider { + return newProjectServiceTarget(azdClient) + }) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/service_target.go new file mode 100644 index 00000000000..81c89ef386b --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/service_target.go @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// aiProjectHost is the azure.yaml service host kind owned by this extension. A +// `host: azure.ai.project` service entry represents the Foundry project and carries its +// model `deployments` (and an optional `endpoint:` for reuse). +const aiProjectHost = "azure.ai.project" + +var _ azdext.ServiceTargetProvider = (*projectServiceTarget)(nil) + +// projectServiceTarget owns the azure.ai.project host. The Foundry project, its model +// deployments, the underlying Account, and RBAC are provisioned at `azd provision` by the +// built-in `microsoft.foundry` Bicep provider (or by the user's own infra), so this +// target has no deploy-time work: Package, Publish, and Deploy are no-ops. It exists so +// the azure.ai.projects extension owns the project host (rather than the shared no-op +// shim in the agents extension) and so `azd deploy`/`azd up` can walk a project entry. +// +// When the project entry sets `endpoint:` (bring-your-own project), provisioning is +// skipped by the provider and azd connects to the existing project; this target still +// has nothing to upsert at deploy. +type projectServiceTarget struct { + azdClient *azdext.AzdClient + serviceConfig *azdext.ServiceConfig +} + +// newProjectServiceTarget creates the azure.ai.project service-target provider. +func newProjectServiceTarget(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { + return &projectServiceTarget{azdClient: azdClient} +} + +// Initialize stores the service configuration; no other setup is required. +func (p *projectServiceTarget) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { + p.serviceConfig = serviceConfig + return nil +} + +// Endpoints returns no endpoints; the project endpoint is surfaced through the azd +// environment (FOUNDRY_PROJECT_ENDPOINT) during provisioning, not here. +func (p *projectServiceTarget) Endpoints( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + targetResource *azdext.TargetResource, +) ([]string, error) { + return nil, nil +} + +// GetTargetResource delegates to azd's default resolver and falls back to a minimal +// target so the deploy pipeline can proceed. +func (p *projectServiceTarget) GetTargetResource( + ctx context.Context, + subscriptionId string, + serviceConfig *azdext.ServiceConfig, + defaultResolver func() (*azdext.TargetResource, error), +) (*azdext.TargetResource, error) { + if defaultResolver != nil { + if target, err := defaultResolver(); err == nil && target != nil { + return target, nil + } + } + return &azdext.TargetResource{SubscriptionId: subscriptionId}, nil +} + +// Package is a no-op; the project has nothing to build or stage. +func (p *projectServiceTarget) Package( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + progress azdext.ProgressReporter, +) (*azdext.ServicePackageResult, error) { + return &azdext.ServicePackageResult{}, nil +} + +// Publish is a no-op; the project has no artifact to publish. +func (p *projectServiceTarget) Publish( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + publishOptions *azdext.PublishOptions, + progress azdext.ProgressReporter, +) (*azdext.ServicePublishResult, error) { + return &azdext.ServicePublishResult{}, nil +} + +// Deploy is a no-op; the project and its model deployments are created at provision time +// by the built-in microsoft.foundry Bicep provider (or the user's infra). Removing the +// service from azure.yaml stops azd managing the project but does not delete it; teardown +// runs through `azd down`. +func (p *projectServiceTarget) Deploy( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + progress azdext.ProgressReporter, +) (*azdext.ServiceDeployResult, error) { + return &azdext.ServiceDeployResult{}, nil +} diff --git a/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json b/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json new file mode 100644 index 00000000000..93d5fa9e5ce --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json", + "title": "Azure AI Foundry project service", + "description": "Service-level configuration for a host: azure.ai.project entry. The project owns its model deployments and an optional endpoint to an existing project. When endpoint is omitted, azd provisions a new project; when set, azd connects to that existing project instead of creating one.", + "type": "object", + "additionalProperties": true, + "properties": { + "endpoint": { + "type": "string", + "description": "Endpoint URL of an existing Foundry project (e.g. https://my-account.services.ai.azure.com/api/projects/my-project). When set, azd connects to this project instead of provisioning a new one. May contain ${VAR} (azd env, resolved client-side)." + }, + "deployments": { + "type": "array", + "description": "Model deployments to upsert on the project.", + "items": { + "oneOf": [ + { "$ref": "#/definitions/Deployment" }, + { "$ref": "#/definitions/FileRef" } + ] + } + } + }, + "definitions": { + "Deployment": { + "type": "object", + "required": ["name", "model", "sku"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Name of the model deployment." + }, + "model": { + "type": "object", + "required": ["format", "name", "version"], + "additionalProperties": false, + "properties": { + "format": { "type": "string", "description": "Model format (e.g., 'OpenAI')." }, + "name": { "type": "string", "description": "Model name (e.g., 'gpt-4.1-mini')." }, + "version": { "type": "string", "description": "Model version (e.g., '2025-04-14')." } + } + }, + "sku": { + "type": "object", + "required": ["capacity", "name"], + "additionalProperties": false, + "properties": { + "capacity": { "type": "integer", "description": "SKU capacity (TPM units)." }, + "name": { "type": "string", "description": "SKU name (e.g., 'Standard', 'GlobalStandard', 'GlobalBatch')." } + } + } + } + }, + "FileRef": { + "type": "object", + "required": ["$ref"], + "additionalProperties": true, + "properties": { + "$ref": { + "type": "string", + "description": "Path to a YAML or JSON file containing the definition. Relative paths resolve from the file containing this $ref. Absolute paths are also accepted; remote URLs are not supported." + } + } + } + } +} diff --git a/cli/azd/extensions/azure.ai.projects/version.txt b/cli/azd/extensions/azure.ai.projects/version.txt index b727e6cbb8a..3228017292e 100644 --- a/cli/azd/extensions/azure.ai.projects/version.txt +++ b/cli/azd/extensions/azure.ai.projects/version.txt @@ -1 +1 @@ -0.1.0-preview \ No newline at end of file +0.1.1-preview \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.routines/CHANGELOG.md b/cli/azd/extensions/azure.ai.routines/CHANGELOG.md index aa36089128c..a96306cfd46 100644 --- a/cli/azd/extensions/azure.ai.routines/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.routines/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## Unreleased + +### Features Added + +- `azd deploy` now expands `${VAR}` references in a routine's `action.input` against the azd environment, leaving Foundry server-side `${{...}}` expressions untouched. + ## 0.1.0-preview (2026-05-28) - Initial preview release of the `azure.ai.routines` extension for managing diff --git a/cli/azd/extensions/azure.ai.routines/extension.yaml b/cli/azd/extensions/azure.ai.routines/extension.yaml index a50fd98ded9..20358dbb62e 100644 --- a/cli/azd/extensions/azure.ai.routines/extension.yaml +++ b/cli/azd/extensions/azure.ai.routines/extension.yaml @@ -1,14 +1,19 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/extension.schema.json capabilities: - custom-commands + - service-target-provider - metadata description: Manage Microsoft Foundry Routines from your terminal. (Preview) displayName: Foundry Routines (Preview) id: azure.ai.routines language: go namespace: ai.routine +providers: + - name: azure.ai.routine + type: service-target + description: Upserts Foundry routines declared as azure.ai.routine services in azure.yaml tags: - ai - routine usage: azd ai routine [options] -version: 0.1.0-preview +version: 0.1.1-preview diff --git a/cli/azd/extensions/azure.ai.routines/go.mod b/cli/azd/extensions/azure.ai.routines/go.mod index d69f817b06e..0f60d383138 100644 --- a/cli/azd/extensions/azure.ai.routines/go.mod +++ b/cli/azd/extensions/azure.ai.routines/go.mod @@ -2,16 +2,25 @@ module azure.ai.routines go 1.26.4 +// TEMPORARY: local validation against the in-tree azd core for the shared +// pkg/foundry helpers (the ${VAR}/${{...}} expander). Remove before merging — +// the core change must land first, then bump the azd dependency. +replace github.com/azure/azure-dev/cli/azd => ../../ + require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/azure/azure-dev/cli/azd v1.25.0 github.com/fatih/color v1.18.0 github.com/spf13/cobra v1.10.1 + github.com/stretchr/testify v1.11.1 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 ) require ( + dario.cat/mergo v1.0.2 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 // indirect @@ -76,7 +85,6 @@ require ( github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/theckman/yacspin v0.13.12 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect @@ -90,15 +98,14 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.49.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/cli/azd/extensions/azure.ai.routines/go.sum b/cli/azd/extensions/azure.ai.routines/go.sum index f1a6eb17ae3..fccf35ac970 100644 --- a/cli/azd/extensions/azure.ai.routines/go.sum +++ b/cli/azd/extensions/azure.ai.routines/go.sum @@ -1,4 +1,6 @@ code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= @@ -17,8 +19,6 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsI github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= @@ -49,8 +49,6 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/azure/azure-dev/cli/azd v1.25.0 h1:gb8Ah5ntUcUAKIDBhCdpx8xxDWSAtCLGyck+Y50QZhw= -github.com/azure/azure-dev/cli/azd v1.25.0/go.mod h1:1ZoZZlUbK8FMTZRibM9hEo/UqSaEXA+SFeIKpya4fsY= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= @@ -249,10 +247,12 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -260,11 +260,13 @@ golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -276,18 +278,18 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/root.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/root.go index 21a9c7b4911..b510775ff2f 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/root.go @@ -27,6 +27,7 @@ func NewRootCommand() *cobra.Command { rootCmd.PersistentFlags().StringP("project-endpoint", "p", "", "Foundry project endpoint URL (overrides env var and config)") + rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) rootCmd.AddCommand(newContextCommand()) rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) rootCmd.AddCommand(newMetadataCommand(rootCmd)) @@ -42,3 +43,13 @@ func NewRootCommand() *cobra.Command { return rootCmd } + +// configureExtensionHost is the listen callback. It registers the +// azure.ai.routine service target so `azd up`/`azd deploy` upsert routines +// declared as services in azure.yaml. +func configureExtensionHost(host *azdext.ExtensionHost) { + azdClient := host.Client() + host.WithServiceTarget(aiRoutineHost, func() azdext.ServiceTargetProvider { + return newRoutineServiceTarget(azdClient) + }) +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go new file mode 100644 index 00000000000..e8e275ebbc0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + + "azure.ai.routines/internal/pkg/routines" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" +) + +// aiRoutineHost is the azure.yaml service host kind owned by this extension. A +// `host: azure.ai.routine` service entry carries one Foundry routine, keyed by +// the routine name, and is upserted at deploy time by routineServiceTarget. A +// routine references an agent by name, so its service must declare uses: on the +// azure.ai.agent service it invokes, which orders the agent ahead of it. +const aiRoutineHost = "azure.ai.routine" + +var _ azdext.ServiceTargetProvider = (*routineServiceTarget)(nil) + +// routineServiceTarget upserts a Foundry routine declared as an azure.ai.routine +// service. The entry's service-level keys are bound directly to the routine API +// model (triggers, action, ...); the routine name is the service key. Package +// and Publish are no-ops because a routine has no build artifact. +type routineServiceTarget struct { + azdClient *azdext.AzdClient + serviceConfig *azdext.ServiceConfig +} + +// newRoutineServiceTarget creates the azure.ai.routine service-target provider. +func newRoutineServiceTarget(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { + return &routineServiceTarget{azdClient: azdClient} +} + +// Initialize stores the service configuration; no other setup is required. +func (p *routineServiceTarget) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { + p.serviceConfig = serviceConfig + return nil +} + +// Endpoints returns no endpoints; a routine service exposes none. +func (p *routineServiceTarget) Endpoints( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + targetResource *azdext.TargetResource, +) ([]string, error) { + return nil, nil +} + +// GetTargetResource delegates to azd's default resolver and falls back to a +// minimal target so the deploy pipeline can proceed; the routine upsert targets +// the Foundry project endpoint, not an ARM resource. +func (p *routineServiceTarget) GetTargetResource( + ctx context.Context, + subscriptionId string, + serviceConfig *azdext.ServiceConfig, + defaultResolver func() (*azdext.TargetResource, error), +) (*azdext.TargetResource, error) { + if defaultResolver != nil { + if target, err := defaultResolver(); err == nil && target != nil { + return target, nil + } + } + return &azdext.TargetResource{SubscriptionId: subscriptionId}, nil +} + +// Package is a no-op; a routine has nothing to build or stage. +func (p *routineServiceTarget) Package( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + progress azdext.ProgressReporter, +) (*azdext.ServicePackageResult, error) { + return &azdext.ServicePackageResult{}, nil +} + +// Publish is a no-op; a routine has no artifact to publish. +func (p *routineServiceTarget) Publish( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + publishOptions *azdext.PublishOptions, + progress azdext.ProgressReporter, +) (*azdext.ServicePublishResult, error) { + return &azdext.ServicePublishResult{}, nil +} + +// Deploy upserts the routine with an idempotent PUT. The service-level keys bind +// directly to the routine API model, so the routine name is taken from the +// service key and never from the body. Removing the service from azure.yaml +// stops azd managing the routine but does not delete it (use +// `azd ai routine delete`). +func (p *routineServiceTarget) Deploy( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + progress azdext.ProgressReporter, +) (*azdext.ServiceDeployResult, error) { + body, err := parseRoutineServiceConfig(serviceConfig) + if err != nil { + return nil, err + } + // The service key is the routine identity; ignore any name in the body. + body.Name = serviceConfig.GetName() + + // Resolve ${VAR} references in the routine's action input against the azd + // environment, leaving Foundry server-side ${{...}} expressions untouched. + if body.Action != nil { + env, err := p.currentEnvValues(ctx) + if err != nil { + return nil, err + } + body.Action.Input = expandRoutineValue(body.Action.Input, env) + } + + if progress != nil { + progress(fmt.Sprintf("Upserting routine %q", serviceConfig.GetName())) + } + + client, err := newRoutineServiceClient(ctx) + if err != nil { + return nil, err + } + + if _, err := client.PutRoutine(ctx, body.Name, body); err != nil { + return nil, fmt.Errorf("upserting routine %q: %w", body.Name, err) + } + + return &azdext.ServiceDeployResult{}, nil +} + +// parseRoutineServiceConfig binds the service-level (inline) routine keys to the +// routine API model, falling back to the deprecated config: shape for azure.yaml +// files written before the per-resource service split. +func parseRoutineServiceConfig(svc *azdext.ServiceConfig) (*routines.Routine, error) { + props := svc.GetAdditionalProperties() + if props == nil || len(props.GetFields()) == 0 { + props = svc.GetConfig() + } + body := &routines.Routine{} + if props == nil { + return body, nil + } + b, err := json.Marshal(props.AsMap()) + if err != nil { + return nil, fmt.Errorf("encoding routine service %q config: %w", svc.GetName(), err) + } + if err := json.Unmarshal(b, body); err != nil { + return nil, fmt.Errorf("parsing routine service %q config: %w", svc.GetName(), err) + } + return body, nil +} + +// newRoutineServiceClient resolves the project endpoint (from the active azd +// environment, global config, or FOUNDRY_PROJECT_ENDPOINT) and an azd developer +// credential, then builds an authenticated routine client for deploy-time +// upserts. It mirrors newRoutineClient but takes no cobra command, since a +// service target has no flags. +func newRoutineServiceClient(ctx context.Context) (*routines.Client, error) { + resolved, err := resolveProjectEndpoint(ctx, "") + if err != nil { + return nil, err + } + cred, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to create Azure credential: %w", err) + } + return routines.NewClient(resolved.Endpoint, cred), nil +} + +// currentEnvValues loads all key-value pairs from the active azd environment, used to +// resolve ${VAR} references in routine fields at deploy time. +func (p *routineServiceTarget) currentEnvValues(ctx context.Context) (map[string]string, error) { + current, err := p.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, fmt.Errorf("resolving current azd environment: %w", err) + } + resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: current.GetEnvironment().GetName(), + }) + if err != nil { + return nil, fmt.Errorf("loading azd environment values: %w", err) + } + values := make(map[string]string, len(resp.GetKeyValues())) + for _, kv := range resp.GetKeyValues() { + values[kv.GetKey()] = kv.GetValue() + } + return values, nil +} + +// expandRoutineValue recursively expands ${VAR} references in every string within a +// routine value (maps, slices, scalars) against the azd environment, preserving Foundry +// server-side ${{...}} expressions. +func expandRoutineValue(value any, env map[string]string) any { + switch typed := value.(type) { + case string: + resolved, err := foundry.ExpandEnv(typed, func(name string) string { return env[name] }) + if err != nil { + return typed + } + return resolved + case map[string]any: + out := make(map[string]any, len(typed)) + for k, v := range typed { + out[k] = expandRoutineValue(v, env) + } + return out + case []any: + out := make([]any, len(typed)) + for i, v := range typed { + out[i] = expandRoutineValue(v, env) + } + return out + default: + return value + } +} diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go new file mode 100644 index 00000000000..4df169c2cd9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestParseRoutineServiceConfig_ServiceLevel(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "description": "nightly summary", + "enabled": true, + "triggers": map[string]any{ + "default": map[string]any{"type": "recurring", "cron_expression": "0 9 * * *"}, + }, + "action": map[string]any{"type": "invoke_agent_responses_api", "agent_name": "summarizer"}, + }) + require.NoError(t, err) + + body, err := parseRoutineServiceConfig(&azdext.ServiceConfig{ + Name: "nightly", + Host: aiRoutineHost, + AdditionalProperties: props, + }) + require.NoError(t, err) + assert.Equal(t, "nightly summary", body.Description) + require.NotNil(t, body.Enabled) + assert.True(t, *body.Enabled) + require.Contains(t, body.Triggers, "default") + assert.Equal(t, "recurring", body.Triggers["default"].Type) + assert.Equal(t, "0 9 * * *", body.Triggers["default"].CronExpression) + require.NotNil(t, body.Action) + assert.Equal(t, "summarizer", body.Action.AgentName) +} + +// TestParseRoutineServiceConfig_ConfigFallback verifies routines written before +// the per-resource service split (config-nested shape) still parse. +func TestParseRoutineServiceConfig_ConfigFallback(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{"description": "legacy"}) + require.NoError(t, err) + + body, err := parseRoutineServiceConfig(&azdext.ServiceConfig{ + Name: "legacy", + Host: aiRoutineHost, + Config: props, + }) + require.NoError(t, err) + assert.Equal(t, "legacy", body.Description) +} diff --git a/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json b/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json new file mode 100644 index 00000000000..40058c5dfaa --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json", + "title": "Azure AI Foundry routine service", + "description": "Service-level configuration for a host: azure.ai.routine entry. The service key is the routine name. A routine is a scheduled or event-driven invocation of an agent declared as an azure.ai.agent service; the routine service must uses: the agent it invokes. The keys bind directly to the Foundry routine API model, so the schema is intentionally permissive while that API stabilizes.", + "type": "object", + "additionalProperties": true, + "properties": { + "description": { + "type": "string", + "description": "Description of the routine." + }, + "enabled": { + "type": "boolean", + "description": "Whether the routine is enabled." + }, + "triggers": { + "type": "object", + "description": "Triggers keyed by a user-defined identifier (commonly 'default'). Each trigger selects a variant via its type (e.g. timer, recurring, github_issue, custom).", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "required": ["type"], + "properties": { + "type": { "type": "string", "description": "Trigger variant." }, + "cron_expression": { "type": "string", "description": "Cron expression for a recurring trigger." }, + "at": { "type": "string", "description": "Fire time for a one-shot timer trigger." } + } + } + }, + "action": { + "type": "object", + "description": "What the routine does when it fires. Invokes an agent by name.", + "additionalProperties": true, + "required": ["type"], + "properties": { + "type": { "type": "string", "description": "Action variant (e.g. invoke_agent_responses_api, invoke_agent_invocations_api)." }, + "agent_name": { "type": "string", "description": "Name of the azure.ai.agent service the routine invokes." }, + "input": { "description": "Static JSON input sent to the agent when the routine fires. Values may use ${VAR} or ${{...}}." } + } + } + } +} diff --git a/cli/azd/extensions/azure.ai.routines/version.txt b/cli/azd/extensions/azure.ai.routines/version.txt index 2c31a296e4c..3228017292e 100644 --- a/cli/azd/extensions/azure.ai.routines/version.txt +++ b/cli/azd/extensions/azure.ai.routines/version.txt @@ -1 +1 @@ -0.1.0-preview +0.1.1-preview \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.skills/extension.yaml b/cli/azd/extensions/azure.ai.skills/extension.yaml index 876258be582..16599e0edbe 100644 --- a/cli/azd/extensions/azure.ai.skills/extension.yaml +++ b/cli/azd/extensions/azure.ai.skills/extension.yaml @@ -10,7 +10,12 @@ requiredAzdVersion: ">1.23.13" language: go capabilities: - custom-commands + - service-target-provider - metadata +providers: + - name: azure.ai.skill + type: service-target + description: Upserts Foundry skills declared as azure.ai.skill services in azure.yaml tags: - ai - skill diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go index fdd93cf3b74..a5f2ddd749e 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go @@ -59,6 +59,12 @@ a Foundry project.`, return rootCmd } -// configureExtensionHost is the listen callback. Skills register no -// lifecycle hooks, so it's a no-op. -func configureExtensionHost(host *azdext.ExtensionHost) { _ = host } +// configureExtensionHost is the listen callback. It registers the +// azure.ai.skill service target so `azd up`/`azd deploy` upsert skills declared +// as services in azure.yaml. +func configureExtensionHost(host *azdext.ExtensionHost) { + azdClient := host.Client() + host.WithServiceTarget(aiSkillHost, func() azdext.ServiceTargetProvider { + return newSkillServiceTarget(azdClient) + }) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/service_target.go new file mode 100644 index 00000000000..c86aa86c5a3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/service_target.go @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "azureaiskills/internal/pkg/skill_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// aiSkillHost is the azure.yaml service host kind owned by this extension. A +// `host: azure.ai.skill` service entry carries one Foundry skill, keyed by the +// skill name, and is reconciled (upserted) at deploy time by skillServiceTarget. +const aiSkillHost = "azure.ai.skill" + +var _ azdext.ServiceTargetProvider = (*skillServiceTarget)(nil) + +// skillServiceConfig is the service-level shape of a `host: azure.ai.skill` +// entry (see schemas/azure.ai.skill.json). The skill name is the azure.yaml +// service key, not a body field. +type skillServiceConfig struct { + Description string `json:"description,omitempty"` + Instructions string `json:"instructions,omitempty"` + Tools []string `json:"tools,omitempty"` +} + +// skillServiceTarget upserts a Foundry skill declared as an azure.ai.skill +// service. Deploy creates a new default skill version from the entry's inline +// instructions; the resource name is the service key. Package and Publish are +// no-ops because a skill has no build artifact. +type skillServiceTarget struct { + azdClient *azdext.AzdClient + serviceConfig *azdext.ServiceConfig +} + +// newSkillServiceTarget creates the azure.ai.skill service-target provider. +func newSkillServiceTarget(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { + return &skillServiceTarget{azdClient: azdClient} +} + +// Initialize stores the service configuration; no other setup is required. +func (p *skillServiceTarget) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { + p.serviceConfig = serviceConfig + return nil +} + +// Endpoints returns no endpoints; a skill service exposes none. +func (p *skillServiceTarget) Endpoints( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + targetResource *azdext.TargetResource, +) ([]string, error) { + return nil, nil +} + +// GetTargetResource delegates to azd's default resolver and falls back to a +// minimal target so the deploy pipeline can proceed; the skill upsert targets +// the Foundry project endpoint, not an ARM resource. +func (p *skillServiceTarget) GetTargetResource( + ctx context.Context, + subscriptionId string, + serviceConfig *azdext.ServiceConfig, + defaultResolver func() (*azdext.TargetResource, error), +) (*azdext.TargetResource, error) { + if defaultResolver != nil { + if target, err := defaultResolver(); err == nil && target != nil { + return target, nil + } + } + return &azdext.TargetResource{SubscriptionId: subscriptionId}, nil +} + +// Package is a no-op; a skill has nothing to build or stage. +func (p *skillServiceTarget) Package( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + progress azdext.ProgressReporter, +) (*azdext.ServicePackageResult, error) { + return &azdext.ServicePackageResult{}, nil +} + +// Publish is a no-op; a skill has no artifact to publish. +func (p *skillServiceTarget) Publish( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + publishOptions *azdext.PublishOptions, + progress azdext.ProgressReporter, +) (*azdext.ServicePublishResult, error) { + return &azdext.ServicePublishResult{}, nil +} + +// Deploy upserts the skill by creating a new default version from the entry's +// instructions. Re-running deploy creates another immutable version rather than +// failing. Removing the service from azure.yaml stops azd managing the skill but +// does not delete it (use `azd ai skill delete`). +func (p *skillServiceTarget) Deploy( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + progress azdext.ProgressReporter, +) (*azdext.ServiceDeployResult, error) { + cfg, err := parseSkillServiceConfig(serviceConfig) + if err != nil { + return nil, err + } + instructions, err := resolveSkillInstructions(serviceConfig, cfg.Instructions) + if err != nil { + return nil, err + } + if instructions == "" { + return nil, fmt.Errorf("skill service %q requires instructions", serviceConfig.GetName()) + } + + if progress != nil { + progress(fmt.Sprintf("Upserting skill %q", serviceConfig.GetName())) + } + + skillCtx, err := resolveSkillContext(ctx, "") + if err != nil { + return nil, err + } + + if _, err := skillCtx.client.CreateVersionInline( + ctx, + serviceConfig.GetName(), + skill_api.CreateVersionRequest{ + InlineContent: &skill_api.SkillInlineContent{ + Description: cfg.Description, + Instructions: instructions, + AllowedTools: cfg.Tools, + }, + Default: true, + }, + ); err != nil { + return nil, fmt.Errorf("upserting skill %q: %w", serviceConfig.GetName(), err) + } + + return &azdext.ServiceDeployResult{}, nil +} + +// parseSkillServiceConfig reads the service-level (inline) skill properties, +// falling back to the deprecated config: shape for azure.yaml files written +// before the per-resource service split. +func parseSkillServiceConfig(svc *azdext.ServiceConfig) (*skillServiceConfig, error) { + props := svc.GetAdditionalProperties() + if props == nil || len(props.GetFields()) == 0 { + props = svc.GetConfig() + } + cfg := &skillServiceConfig{} + if props == nil { + return cfg, nil + } + b, err := json.Marshal(props.AsMap()) + if err != nil { + return nil, fmt.Errorf("encoding skill service %q config: %w", svc.GetName(), err) + } + if err := json.Unmarshal(b, cfg); err != nil { + return nil, fmt.Errorf("parsing skill service %q config: %w", svc.GetName(), err) + } + return cfg, nil +} + +func resolveSkillInstructions(svc *azdext.ServiceConfig, instructions string) (string, error) { + if !isInstructionFilePath(instructions) { + return instructions, nil + } + + path := strings.TrimSpace(instructions) + if !filepath.IsAbs(path) { + // Reject path traversal: a relative instructions path is read from disk + // under the service directory, so a value like "../../secret.md" must not + // be allowed to escape it via filepath.Join. + if hasParentTraversal(path) { + return "", fmt.Errorf( + "skill instructions path %q must not contain '..' or escape the service directory", instructions) + } + baseDir := svc.GetRelativePath() + if baseDir == "" { + baseDir = "." + } + path = filepath.Join(baseDir, path) + } + + data, err := readFileWithLimit(path) + if err != nil { + return "", err + } + return string(data), nil +} + +// hasParentTraversal reports whether a relative path contains a ".." segment +// that could escape its base directory, treating both '/' and '\' as separators. +func hasParentTraversal(p string) bool { + for _, seg := range strings.Split(strings.ReplaceAll(p, "\\", "/"), "/") { + if seg == ".." { + return true + } + } + return false +} + +func isInstructionFilePath(instructions string) bool { + switch strings.ToLower(filepath.Ext(strings.TrimSpace(instructions))) { + case ".md", ".txt": + return true + default: + return false + } +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/service_target_test.go new file mode 100644 index 00000000000..7655d6a7c57 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/service_target_test.go @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestParseSkillServiceConfig_ServiceLevel(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "description": "code review skill", + "instructions": "Review code for correctness.", + "tools": []any{"code_interpreter"}, + }) + require.NoError(t, err) + + cfg, err := parseSkillServiceConfig(&azdext.ServiceConfig{ + Name: "code-review", + Host: aiSkillHost, + AdditionalProperties: props, + }) + require.NoError(t, err) + assert.Equal(t, "code review skill", cfg.Description) + assert.Equal(t, "Review code for correctness.", cfg.Instructions) + assert.Equal(t, []string{"code_interpreter"}, cfg.Tools) +} + +// TestParseSkillServiceConfig_ConfigFallback verifies skills written before the +// per-resource service split (config-nested shape) still parse. +func TestParseSkillServiceConfig_ConfigFallback(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "instructions": "legacy shape", + }) + require.NoError(t, err) + + cfg, err := parseSkillServiceConfig(&azdext.ServiceConfig{ + Name: "legacy", + Host: aiSkillHost, + Config: props, + }) + require.NoError(t, err) + assert.Equal(t, "legacy shape", cfg.Instructions) +} + +func TestParseSkillServiceConfig_Empty(t *testing.T) { + t.Parallel() + + cfg, err := parseSkillServiceConfig(&azdext.ServiceConfig{Name: "empty", Host: aiSkillHost}) + require.NoError(t, err) + assert.Empty(t, cfg.Instructions) +} + +func TestResolveSkillInstructions_Inline(t *testing.T) { + t.Parallel() + + got, err := resolveSkillInstructions(&azdext.ServiceConfig{Name: "inline"}, "Review code for correctness.") + require.NoError(t, err) + assert.Equal(t, "Review code for correctness.", got) +} + +func TestResolveSkillInstructions_FilePath(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "instructions.md"), []byte("Review from file."), 0600)) + + got, err := resolveSkillInstructions( + &azdext.ServiceConfig{Name: "file", RelativePath: dir}, + "instructions.md", + ) + require.NoError(t, err) + assert.Equal(t, "Review from file.", got) +} + +// TestResolveSkillInstructions_PathTraversal verifies a relative instructions +// path that tries to escape the service directory with ".." is rejected rather +// than read from disk. +func TestResolveSkillInstructions_PathTraversal(t *testing.T) { + t.Parallel() + + for _, instructions := range []string{"../secret.md", "../../etc/passwd.txt", "sub/../../escape.md"} { + _, err := resolveSkillInstructions( + &azdext.ServiceConfig{Name: "traversal", RelativePath: t.TempDir()}, + instructions, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not contain '..'") + } +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go index 37cb811dd55..0110f331cd1 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go @@ -540,8 +540,8 @@ skill of the same name before creating.`, return cmd } -// readFileWithLimit reads up to 1 MiB from path. SKILL.md is small in practice; -// the cap guards against reading a giant file by accident. +// readFileWithLimit reads up to 1 MiB from path. Skill files are small in +// practice; the cap guards against reading a giant file by accident. func readFileWithLimit(path string) ([]byte, error) { info, err := os.Stat(path) if err != nil { @@ -554,15 +554,15 @@ func readFileWithLimit(path string) ([]byte, error) { if info.IsDir() { return nil, exterrors.Validation( exterrors.CodeInvalidSkillFile, - fmt.Sprintf("--file %s is a directory; expected a SKILL.md file", path), - "pass a single .md file", + fmt.Sprintf("%s is a directory; expected a skill file", path), + "pass a single file", ) } const maxBytes = 1 << 20 if info.Size() > maxBytes { return nil, exterrors.Validation( exterrors.CodeInvalidSkillFile, - fmt.Sprintf("%s exceeds the 1 MiB SKILL.md size limit (got %d bytes)", path, info.Size()), + fmt.Sprintf("%s exceeds the 1 MiB skill file size limit (got %d bytes)", path, info.Size()), "split the file into smaller assets and use a package upload", ) } diff --git a/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json b/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json new file mode 100644 index 00000000000..ce4f6bf76bc --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json", + "title": "Azure AI Foundry skill service", + "description": "Service-level configuration for a host: azure.ai.skill entry. The service key is the skill name. A skill is a reusable capability bundle (instructions + tools) an agent attaches at run time.", + "type": "object", + "additionalProperties": true, + "properties": { + "description": { + "type": "string", + "description": "Description of the skill." + }, + "instructions": { + "type": "string", + "description": "Inline instruction text, or a file path (.md, .txt) the extension reads at deploy time. A relative path resolves from the service path when set, otherwise from the current project directory." + }, + "tools": { + "type": "array", + "description": "Tool type names the skill uses (must be declared on a toolbox the agent references, or attached directly to the agent's tools list).", + "items": { "type": "string" } + } + } +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md b/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md index 232276e97ad..baaad8807c7 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features Added + +- The `azure.ai.toolboxes` extension now registers an `azure.ai.toolbox` service-target host. `azd deploy`/`azd up` upsert each `host: azure.ai.toolbox` service in `azure.yaml` as a new toolbox version, resolving named `connection` references to their project connection IDs, expanding `${VAR}` references, and publishing the toolbox MCP endpoint to the azd environment. + ## 0.1.1-preview (2026-06-19) ### Features diff --git a/cli/azd/extensions/azure.ai.toolboxes/extension.yaml b/cli/azd/extensions/azure.ai.toolboxes/extension.yaml index 16215ae4ab8..612b8be3008 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/extension.yaml +++ b/cli/azd/extensions/azure.ai.toolboxes/extension.yaml @@ -1,14 +1,19 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/extension.schema.json capabilities: - custom-commands + - service-target-provider - metadata description: Manage Microsoft Foundry Toolboxes from your terminal. (Preview) displayName: Foundry Toolboxes (Preview) id: azure.ai.toolboxes language: go namespace: ai.toolbox +providers: + - name: azure.ai.toolbox + type: service-target + description: Upserts Foundry toolboxes declared as azure.ai.toolbox services in azure.yaml tags: - ai - toolbox usage: azd ai toolbox [options] -version: 0.1.1-preview +version: 0.1.2-preview diff --git a/cli/azd/extensions/azure.ai.toolboxes/go.mod b/cli/azd/extensions/azure.ai.toolboxes/go.mod index c7b6a285883..83c9963b07a 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/go.mod +++ b/cli/azd/extensions/azure.ai.toolboxes/go.mod @@ -2,15 +2,23 @@ module azure.ai.toolboxes go 1.26.4 +// TEMPORARY: local validation against the in-tree azd core for the shared +// pkg/foundry helpers (the $ref resolver and ${VAR}/${{...}} expander). Remove +// before merging — the core change must land first, then bump the azd dependency. +replace github.com/azure/azure-dev/cli/azd => ../../ + require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/azure/azure-dev/cli/azd v1.25.0 github.com/spf13/cobra v1.10.1 + github.com/stretchr/testify v1.11.1 google.golang.org/grpc v1.80.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( + dario.cat/mergo v1.0.2 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 // indirect @@ -76,7 +84,6 @@ require ( github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/theckman/yacspin v0.13.12 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect @@ -90,14 +97,15 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.49.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/cli/azd/extensions/azure.ai.toolboxes/go.sum b/cli/azd/extensions/azure.ai.toolboxes/go.sum index 861ed3343a9..fccf35ac970 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/go.sum +++ b/cli/azd/extensions/azure.ai.toolboxes/go.sum @@ -1,4 +1,6 @@ code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= @@ -47,8 +49,6 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/azure/azure-dev/cli/azd v1.25.0 h1:gb8Ah5ntUcUAKIDBhCdpx8xxDWSAtCLGyck+Y50QZhw= -github.com/azure/azure-dev/cli/azd v1.25.0/go.mod h1:1ZoZZlUbK8FMTZRibM9hEo/UqSaEXA+SFeIKpya4fsY= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= @@ -247,10 +247,12 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -258,11 +260,13 @@ golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -274,18 +278,18 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go index dfcffe5fdd2..2f1c13dafde 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go @@ -53,6 +53,17 @@ to promote a version.`, rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) rootCmd.AddCommand(newMetadataCommand(rootCmd)) + rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) return rootCmd } + +// configureExtensionHost is the listen callback. It registers the +// azure.ai.toolbox service target so `azd up`/`azd deploy` upsert toolboxes +// declared as services in azure.yaml. +func configureExtensionHost(host *azdext.ExtensionHost) { + azdClient := host.Client() + host.WithServiceTarget(aiToolboxHost, func() azdext.ServiceTargetProvider { + return newToolboxServiceTarget(azdClient) + }) +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go new file mode 100644 index 00000000000..69dd7e27075 --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + + "azure.ai.toolboxes/internal/foundry/projectctx" + "azure.ai.toolboxes/internal/pkg/azure" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" +) + +// aiToolboxHost is the azure.yaml service host kind owned by this extension. A +// `host: azure.ai.toolbox` service entry carries one Foundry toolbox (a named bundle +// of connection-backed tools), keyed by the toolbox name, and is reconciled (a new +// version is upserted) at deploy time by toolboxServiceTarget instead of being layered +// into provisioning. +const aiToolboxHost = "azure.ai.toolbox" + +var _ azdext.ServiceTargetProvider = (*toolboxServiceTarget)(nil) + +// toolboxServiceConfig is the service-level shape of a `host: azure.ai.toolbox` entry +// (see schemas/azure.ai.toolbox.json). The toolbox name is the azure.yaml service key, +// not a body field. Each tool is a verbatim data-plane tool object; a tool that names a +// `connection` is resolved to its project_connection_id at deploy time. +type toolboxServiceConfig struct { + Description string `json:"description,omitempty"` + Tools []map[string]any `json:"tools,omitempty"` +} + +// toolboxServiceTarget upserts a Foundry toolbox declared as an azure.ai.toolbox +// service. Deploy creates a new toolbox version from the entry's tools; the resource +// name is the service key. Package and Publish are no-ops because a toolbox has no build +// artifact. +type toolboxServiceTarget struct { + azdClient *azdext.AzdClient + serviceConfig *azdext.ServiceConfig + resolver connectionResolver +} + +// newToolboxServiceTarget creates the azure.ai.toolbox service-target provider. +func newToolboxServiceTarget(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { + return &toolboxServiceTarget{azdClient: azdClient, resolver: defaultConnectionResolver{}} +} + +// Initialize stores the service configuration; no other setup is required. +func (p *toolboxServiceTarget) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { + p.serviceConfig = serviceConfig + return nil +} + +// Endpoints returns no endpoints; a toolbox service exposes none directly (its MCP +// endpoint is published to the azd environment during Deploy). +func (p *toolboxServiceTarget) Endpoints( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + targetResource *azdext.TargetResource, +) ([]string, error) { + return nil, nil +} + +// GetTargetResource delegates to azd's default resolver and falls back to a minimal +// target so the deploy pipeline can proceed; the toolbox upsert targets the Foundry +// project endpoint, not an ARM resource azd tracks. +func (p *toolboxServiceTarget) GetTargetResource( + ctx context.Context, + subscriptionId string, + serviceConfig *azdext.ServiceConfig, + defaultResolver func() (*azdext.TargetResource, error), +) (*azdext.TargetResource, error) { + if defaultResolver != nil { + if target, err := defaultResolver(); err == nil && target != nil { + return target, nil + } + } + return &azdext.TargetResource{SubscriptionId: subscriptionId}, nil +} + +// Package is a no-op; a toolbox has nothing to build or stage. +func (p *toolboxServiceTarget) Package( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + progress azdext.ProgressReporter, +) (*azdext.ServicePackageResult, error) { + return &azdext.ServicePackageResult{}, nil +} + +// Publish is a no-op; a toolbox has no artifact to publish. +func (p *toolboxServiceTarget) Publish( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + publishOptions *azdext.PublishOptions, + progress azdext.ProgressReporter, +) (*azdext.ServicePublishResult, error) { + return &azdext.ServicePublishResult{}, nil +} + +// Deploy upserts the toolbox by creating a new version from the entry's tools. Tool +// entries that name a `connection` are resolved to their project_connection_id (the +// `uses:` edge guarantees the connection is reconciled first). ${VAR} references resolve +// against the azd environment; Foundry ${{...}} expressions pass through untouched. +// Removing the service from azure.yaml stops azd managing the toolbox but does not delete +// it (use `azd ai toolbox delete`). +func (p *toolboxServiceTarget) Deploy( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + progress azdext.ProgressReporter, +) (*azdext.ServiceDeployResult, error) { + cfg, err := parseToolboxServiceConfig(serviceConfig) + if err != nil { + return nil, err + } + name := serviceConfig.GetName() + + resolved, err := projectctx.Resolve(ctx, projectctx.ResolveOpts{}) + if err != nil { + return nil, err + } + endpoint := resolved.Endpoint + + env, err := p.currentEnvValues(ctx) + if err != nil { + return nil, err + } + + tools, err := p.buildToolEntries(ctx, endpoint, cfg.Tools, env) + if err != nil { + return nil, err + } + + if progress != nil { + progress(fmt.Sprintf("Upserting toolbox %q", name)) + } + + client, err := newToolboxClient(endpoint) + if err != nil { + return nil, err + } + + created, err := client.CreateToolboxVersion(ctx, name, &azure.CreateToolboxVersionRequest{ + Description: cfg.Description, + Tools: tools, + }) + if err != nil { + return nil, fmt.Errorf("upserting toolbox %q: %w", name, err) + } + + // Surface the toolbox's MCP endpoint to agents (and the developer) without re-running. + mcpURL := buildToolboxMcpURL(endpoint, name, created.Version) + if err := setToolboxEndpointEnvFunc(ctx, name, mcpURL); err != nil { + return nil, err + } + + return &azdext.ServiceDeployResult{}, nil +} + +// buildToolEntries renders each declared tool into a data-plane tool object: ${VAR} +// references are expanded and a tool naming a `connection` has that name resolved to a +// project_connection_id (and server_url when the connection exposes a target). +func (p *toolboxServiceTarget) buildToolEntries( + ctx context.Context, + endpoint string, + tools []map[string]any, + env map[string]string, +) ([]map[string]any, error) { + out := make([]map[string]any, 0, len(tools)) + for _, tool := range tools { + entry, ok := expandToolboxValue(tool, env).(map[string]any) + if !ok { + continue + } + if connName, isString := entry["connection"].(string); isString && connName != "" { + conn, err := p.resolver.resolveConnection(ctx, endpoint, connName) + if err != nil { + return nil, err + } + entry["project_connection_id"] = conn.ID + if conn.Target != "" { + if _, set := entry["server_url"]; !set { + entry["server_url"] = conn.Target + } + } + delete(entry, "connection") + } + out = append(out, entry) + } + return out, nil +} + +// parseToolboxServiceConfig reads the service-level (inline) toolbox properties, falling +// back to the deprecated config: shape for azure.yaml files written before the +// per-resource service split. +func parseToolboxServiceConfig(svc *azdext.ServiceConfig) (*toolboxServiceConfig, error) { + props := svc.GetAdditionalProperties() + if props == nil || len(props.GetFields()) == 0 { + props = svc.GetConfig() + } + cfg := &toolboxServiceConfig{} + if props == nil { + return cfg, nil + } + b, err := json.Marshal(props.AsMap()) + if err != nil { + return nil, fmt.Errorf("encoding toolbox service %q config: %w", svc.GetName(), err) + } + if err := json.Unmarshal(b, cfg); err != nil { + return nil, fmt.Errorf("parsing toolbox service %q config: %w", svc.GetName(), err) + } + return cfg, nil +} + +// currentEnvValues loads all key-value pairs from the active azd environment, used to +// resolve ${VAR} references in tool fields at deploy time. +func (p *toolboxServiceTarget) currentEnvValues(ctx context.Context) (map[string]string, error) { + current, err := p.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, fmt.Errorf("resolving current azd environment: %w", err) + } + resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: current.GetEnvironment().GetName(), + }) + if err != nil { + return nil, fmt.Errorf("loading azd environment values: %w", err) + } + values := make(map[string]string, len(resp.GetKeyValues())) + for _, kv := range resp.GetKeyValues() { + values[kv.GetKey()] = kv.GetValue() + } + return values, nil +} + +// expandToolboxValue recursively expands ${VAR} references in every string within a tool +// value (maps, slices, scalars) against the azd environment, preserving Foundry +// server-side ${{...}} expressions. +func expandToolboxValue(value any, env map[string]string) any { + switch typed := value.(type) { + case string: + resolved, err := foundry.ExpandEnv(typed, func(name string) string { return env[name] }) + if err != nil { + return typed + } + return resolved + case map[string]any: + out := make(map[string]any, len(typed)) + for k, v := range typed { + out[k] = expandToolboxValue(v, env) + } + return out + case []any: + out := make([]any, len(typed)) + for i, v := range typed { + out[i] = expandToolboxValue(v, env) + } + return out + default: + return value + } +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go new file mode 100644 index 00000000000..2c08f4c949c --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +// stubToolboxConnResolver is a connectionResolver test double that returns a fixed +// project connection so buildToolEntries can be exercised without a live project. +type stubToolboxConnResolver struct { + id string + target string +} + +func (s stubToolboxConnResolver) resolveConnection( + _ context.Context, _, name string, +) (*projectConnection, error) { + return &projectConnection{ID: s.id, Name: name, Target: s.target}, nil +} + +func TestParseToolboxServiceConfig_ServiceLevel(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "description": "research tools", + "tools": []any{ + map[string]any{"type": "web_search"}, + map[string]any{"type": "mcp", "connection": "github-mcp"}, + }, + }) + require.NoError(t, err) + + cfg, err := parseToolboxServiceConfig(&azdext.ServiceConfig{ + Name: "research", + Host: aiToolboxHost, + AdditionalProperties: props, + }) + require.NoError(t, err) + assert.Equal(t, "research tools", cfg.Description) + require.Len(t, cfg.Tools, 2) + assert.Equal(t, "web_search", cfg.Tools[0]["type"]) + assert.Equal(t, "github-mcp", cfg.Tools[1]["connection"]) +} + +func TestBuildToolEntries_ResolvesConnectionRef(t *testing.T) { + t.Parallel() + + tgt := &toolboxServiceTarget{ + resolver: stubToolboxConnResolver{id: "/sub/conn/github-mcp", target: "https://mcp.example.com"}, + } + tools := []map[string]any{ + {"type": "web_search"}, + {"type": "mcp", "connection": "github-mcp"}, + } + + out, err := tgt.buildToolEntries(context.Background(), "https://proj.example.com", tools, nil) + require.NoError(t, err) + require.Len(t, out, 2) + + // Non-connection tool passes through unchanged. + assert.Equal(t, "web_search", out[0]["type"]) + + // Connection-backed tool gets project_connection_id + server_url; the connection + // name key is dropped. + assert.Equal(t, "/sub/conn/github-mcp", out[1]["project_connection_id"]) + assert.Equal(t, "https://mcp.example.com", out[1]["server_url"]) + _, hasConnection := out[1]["connection"] + assert.False(t, hasConnection) +} + +func TestExpandToolboxValue(t *testing.T) { + t.Parallel() + + env := map[string]string{"MCP_URL": "https://resolved.example.com"} + in := map[string]any{ + "type": "mcp", + "server_url": "${MCP_URL}", + "headers": []any{"x-secret: ${{secrets.token}}"}, + } + + out, ok := expandToolboxValue(in, env).(map[string]any) + require.True(t, ok) + assert.Equal(t, "https://resolved.example.com", out["server_url"]) + // Foundry ${{...}} passes through untouched. + assert.Equal(t, []any{"x-secret: ${{secrets.token}}"}, out["headers"]) +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json b/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json new file mode 100644 index 00000000000..46b35dbfd68 --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json", + "title": "Azure AI Foundry toolbox service", + "description": "Service-level configuration for a host: azure.ai.toolbox entry. The service key is the toolbox name. A toolbox is a named bundle of connection-backed tools that agents reference by name.", + "type": "object", + "additionalProperties": true, + "properties": { + "description": { + "type": "string", + "description": "Description of the toolbox." + }, + "tools": { + "type": "array", + "description": "List of tools in the toolbox.", + "items": { + "type": "object", + "required": ["type"], + "additionalProperties": true, + "properties": { + "type": { + "type": "string", + "description": "Tool type (e.g., 'web_search', 'code_interpreter', 'file_search', 'mcp', 'azure_ai_search', 'bing_grounding', 'openapi')." + }, + "connection": { + "type": "string", + "description": "Name of an azure.ai.connection service (required for connection-backed tool types like 'mcp', 'azure_ai_search')." + } + } + } + } + } +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/version.txt b/cli/azd/extensions/azure.ai.toolboxes/version.txt index 9ff8406fee4..dd74174b8da 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/version.txt +++ b/cli/azd/extensions/azure.ai.toolboxes/version.txt @@ -1 +1 @@ -0.1.1-preview +0.1.2-preview \ No newline at end of file diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index 741f831ef64..3b5464caad2 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -202,6 +202,15 @@ var ( Classification: SystemMetadata, Purpose: PerformanceAndHealth, } + // Whether the project contains a Foundry agent service (host: azure.ai.agent) + // still using the deprecated config-nested shape (a populated `config:` block) + // instead of service-level properties. Tracks migration of older Foundry + // projects off the legacy shape. + FoundryAgentLegacyConfigKey = AttributeKey{ + Key: attribute.Key("foundry.agent.legacy_config_shape"), + Classification: SystemMetadata, + Purpose: FeatureInsight, + } ) // Platform related attributes for integrations like devcenter / ADE diff --git a/cli/azd/pkg/foundry/errors.go b/cli/azd/pkg/foundry/errors.go new file mode 100644 index 00000000000..fa8afdf44c1 --- /dev/null +++ b/cli/azd/pkg/foundry/errors.go @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package foundry + +import "github.com/azure/azure-dev/cli/azd/pkg/azdext" + +// CodeInvalidFileRef is the structured error code emitted when a $ref include cannot be +// resolved or written. Foundry extensions surface it through azd's structured error channel. +const CodeInvalidFileRef = "invalid_file_ref" + +// fileRefValidation returns a validation error for a malformed or unreadable $ref include. +// It builds the same azdext.LocalError shape the Foundry extensions use, so callers can +// branch on Code/Category regardless of which extension owns the resource. +func fileRefValidation(message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: CodeInvalidFileRef, + Category: azdext.LocalErrorCategoryValidation, + Suggestion: suggestion, + } +} + +// fileRefInternal returns an internal error for an unexpected $ref resolution failure. +func fileRefInternal(message string) error { + return &azdext.LocalError{ + Message: message, + Code: CodeInvalidFileRef, + Category: azdext.LocalErrorCategoryInternal, + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/includes.go b/cli/azd/pkg/foundry/includes.go similarity index 93% rename from cli/azd/extensions/azure.ai.agents/internal/project/includes.go rename to cli/azd/pkg/foundry/includes.go index 5407bf757f5..7cd27c2f20f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/includes.go +++ b/cli/azd/pkg/foundry/includes.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package foundry import ( "fmt" @@ -12,8 +12,6 @@ import ( "strings" "go.yaml.in/yaml/v3" - - "azureaiagent/internal/exterrors" ) // refKey is the include directive key. Any object that contains it is replaced by the loaded @@ -74,7 +72,7 @@ func ResolveFileRefs(cfg map[string]any, projectRoot string) (map[string]any, er out, ok := resolved.(map[string]any) if !ok { // resolveValue always returns a map for a map input; this is unreachable in practice. - return nil, exterrors.Internal(exterrors.CodeInvalidFileRef, "resolved Foundry config is not a mapping") + return nil, fileRefInternal("resolved Foundry config is not a mapping") } return out, nil } @@ -134,8 +132,7 @@ func resolveMapEntry(key string, child any, baseDir, projectRoot string, chain [ func resolveRef(directive map[string]any, baseDir, projectRoot string, chain []string) (any, error) { ref, ok := directive[refKey].(string) if !ok { - return nil, exterrors.Validation( - exterrors.CodeInvalidFileRef, + return nil, fileRefValidation( fmt.Sprintf("%s value must be a string, got %T", refKey, directive[refKey]), fmt.Sprintf("Set %s to a relative or absolute path to a YAML or JSON file.", refKey), ) @@ -147,15 +144,13 @@ func resolveRef(directive map[string]any, baseDir, projectRoot string, chain []s } if slices.Contains(chain, target) { - return nil, exterrors.Validation( - exterrors.CodeInvalidFileRef, + return nil, fileRefValidation( fmt.Sprintf("cyclic %s include detected at %q", refKey, target), "Remove the circular reference between the included files.", ) } if len(chain) >= maxRefDepth { - return nil, exterrors.Validation( - exterrors.CodeInvalidFileRef, + return nil, fileRefValidation( fmt.Sprintf("%s include nesting exceeds %d levels at %q", refKey, maxRefDepth, target), "Reduce the depth of nested $ref includes.", ) @@ -175,8 +170,7 @@ func resolveRef(directive map[string]any, baseDir, projectRoot string, chain []s out, ok := resolvedLoaded.(map[string]any) if !ok { // loadRefFile guarantees a mapping, so resolveValue returns a map; unreachable in practice. - return nil, exterrors.Validation( - exterrors.CodeInvalidFileRef, + return nil, fileRefValidation( fmt.Sprintf("%s file %q must contain a YAML or JSON object", refKey, target), "The referenced file must define an object, not a list or scalar.", ) @@ -203,15 +197,13 @@ func resolveRef(directive map[string]any, baseDir, projectRoot string, chain []s func refTargetPath(ref, baseDir string) (string, error) { ref = strings.TrimSpace(ref) if ref == "" { - return "", exterrors.Validation( - exterrors.CodeInvalidFileRef, + return "", fileRefValidation( fmt.Sprintf("%s value must not be empty", refKey), "Set $ref to a relative or absolute path to a YAML or JSON file.", ) } if remoteRefPattern.MatchString(ref) { - return "", exterrors.Validation( - exterrors.CodeInvalidFileRef, + return "", fileRefValidation( fmt.Sprintf("%s %q is a URL; remote includes are not supported yet", refKey, ref), "Use a local file path. Download the file and reference it by a relative or absolute path.", ) @@ -229,8 +221,7 @@ func loadRefFile(path string) (map[string]any, error) { // itself (design spec §2.4 treats includes as trusted input). data, err := os.ReadFile(path) if err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidFileRef, + return nil, fileRefValidation( fmt.Sprintf("cannot read %s file %q: %v", refKey, path, err), "Check that the path is correct and the file exists and is readable.", ) @@ -238,15 +229,13 @@ func loadRefFile(path string) (map[string]any, error) { var out map[string]any if err := yaml.Unmarshal(data, &out); err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidFileRef, + return nil, fileRefValidation( fmt.Sprintf("%s file %q is not a valid YAML or JSON object: %v", refKey, path, err), "Fix the file so it parses as a YAML or JSON object.", ) } if out == nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidFileRef, + return nil, fileRefValidation( fmt.Sprintf("%s file %q is empty or not a mapping", refKey, path), "The referenced file must contain a YAML or JSON object.", ) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit.go b/cli/azd/pkg/foundry/includes_edit.go similarity index 98% rename from cli/azd/extensions/azure.ai.agents/internal/project/includes_edit.go rename to cli/azd/pkg/foundry/includes_edit.go index 6608cdc0f8e..620f6247a96 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit.go +++ b/cli/azd/pkg/foundry/includes_edit.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package foundry import ( "bytes" @@ -14,8 +14,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/yamlnode" "github.com/braydonk/yaml" - - "azureaiagent/internal/exterrors" ) // ErrServiceNotFound is returned when a named service entry is absent and creation was not @@ -60,8 +58,7 @@ func LoadYAMLDocument(path string) (*YAMLDocument, error) { // trust level as azure.yaml itself (design spec §2.4 treats includes as trusted input). data, err := os.ReadFile(path) if err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidFileRef, + return nil, fileRefValidation( fmt.Sprintf("cannot read YAML file %q: %v", path, err), "Check that the path is correct and the file exists and is readable.", ) @@ -81,8 +78,7 @@ func ParseYAMLDocument(path string, data []byte) (*YAMLDocument, error) { decoder := yaml.NewDecoder(bytes.NewReader(data)) decoder.SetScanBlockScalarAsLiteral(true) if err := decoder.Decode(&doc.root); err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidFileRef, + return nil, fileRefValidation( fmt.Sprintf("YAML file %q is not valid: %v", path, err), "Fix the file so it parses as a YAML document.", ) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go b/cli/azd/pkg/foundry/includes_edit_test.go similarity index 99% rename from cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go rename to cli/azd/pkg/foundry/includes_edit_test.go index c7c4e1655ca..c6ad8eec74a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go +++ b/cli/azd/pkg/foundry/includes_edit_test.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package foundry import ( "os" diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/includes_test.go b/cli/azd/pkg/foundry/includes_test.go similarity index 99% rename from cli/azd/extensions/azure.ai.agents/internal/project/includes_test.go rename to cli/azd/pkg/foundry/includes_test.go index e58add38b61..43a0520bdd3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/includes_test.go +++ b/cli/azd/pkg/foundry/includes_test.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package foundry import ( "os" @@ -12,8 +12,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.yaml.in/yaml/v3" - - "azureaiagent/internal/exterrors" ) // writeFile writes content to dir/name (creating parent directories) and returns nothing; it @@ -41,7 +39,7 @@ func requireFileRefError(t *testing.T, err error, substr string) { require.Error(t, err) var localErr *azdext.LocalError require.ErrorAs(t, err, &localErr) - assert.Equal(t, exterrors.CodeInvalidFileRef, localErr.Code) + assert.Equal(t, CodeInvalidFileRef, localErr.Code) assert.Contains(t, localErr.Message, substr) } diff --git a/cli/azd/pkg/foundry/templating.go b/cli/azd/pkg/foundry/templating.go new file mode 100644 index 00000000000..1b85d3016f8 --- /dev/null +++ b/cli/azd/pkg/foundry/templating.go @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package foundry holds helpers shared across the Microsoft Foundry azd +// extensions (agents, projects, connections, toolboxes, skills, routines). +package foundry + +import ( + "fmt" + "regexp" + "strings" + + "github.com/drone/envsubst" +) + +// foundryTemplatePattern matches Foundry server-side ${{...}} expressions. These are resolved +// by Foundry at runtime (for example ${{connections.x.credentials.key}} or ${{event.body}}) +// and must survive azd's client-side ${VAR} expansion untouched. The (?s) flag lets the span +// cross newlines; the lazy quantifier stops at the first closing }}. +var foundryTemplatePattern = regexp.MustCompile(`(?s)\$\{\{.*?\}\}`) + +// foundrySentinelBase is the prefix for placeholders that temporarily replace ${{...}} spans +// while ${VAR} expansion runs. It contains no '$', '{', or '}', so drone/envsubst (which only +// expands the braced ${...} form) copies it through untouched, even when a literal '$' precedes +// it. +const foundrySentinelBase = "azdFoundryTemplateSpan_" + +// ExpandEnv expands ${VAR} references in value against the azd environment (via mapping) while +// preserving Foundry server-side ${{...}} expressions verbatim. It supports default values +// (${VAR:-default}) and multiple expressions, matching drone/envsubst semantics for the ${VAR} +// portion. On expansion error the original value is returned unchanged alongside the error. +// +// This is the single shared expander every Foundry field, in every Foundry extension, should +// route through so ${VAR} and ${{...}} are handled consistently. drone/envsubst cannot parse +// ${{...}}, so each span is masked with a sentinel placeholder, a single Eval expands the +// ${VAR} references, then the spans are restored. Masking rather than splitting preserves full +// ${VAR:-default} semantics even when a ${{...}} expression is the default value (e.g. +// ${MISSING:-${{event.body}}}). A ${VAR} inside a ${{...}} span is left as-is, since the span +// is reserved for Foundry. +func ExpandEnv(value string, mapping func(string) string) (string, error) { + spans := foundryTemplatePattern.FindAllString(value, -1) + if len(spans) == 0 { + expanded, err := envsubst.Eval(value, mapping) + if err != nil { + return value, err + } + return expanded, nil + } + + // Choose a sentinel that does not already occur in the input so restoration is exact. + sentinel := foundrySentinelBase + for strings.Contains(value, sentinel) { + sentinel += "_" + } + + index := 0 + masked := foundryTemplatePattern.ReplaceAllStringFunc(value, func(string) string { + placeholder := fmt.Sprintf("%s%d_", sentinel, index) + index++ + return placeholder + }) + + expanded, err := envsubst.Eval(masked, mapping) + if err != nil { + return value, err + } + + for i, span := range spans { + expanded = strings.Replace(expanded, fmt.Sprintf("%s%d_", sentinel, i), span, 1) + } + return expanded, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/templating_test.go b/cli/azd/pkg/foundry/templating_test.go similarity index 99% rename from cli/azd/extensions/azure.ai.agents/internal/project/templating_test.go rename to cli/azd/pkg/foundry/templating_test.go index bd18c5ac1c2..6340a47a7f1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/templating_test.go +++ b/cli/azd/pkg/foundry/templating_test.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package project +package foundry import ( "testing" diff --git a/cli/azd/pkg/project/project.go b/cli/azd/pkg/project/project.go index 03ca5838cf7..b1726288a43 100644 --- a/cli/azd/pkg/project/project.go +++ b/cli/azd/pkg/project/project.go @@ -231,10 +231,17 @@ func Load(ctx context.Context, projectFilePath string) (*ProjectConfig, error) { hosts := make([]string, len(projectConfig.Services)) languages := make([]string, len(projectConfig.Services)) i := 0 + legacyAgentConfig := false for _, svcConfig := range projectConfig.Services { hosts[i] = string(svcConfig.Host) languages[i] = string(svcConfig.Language) i++ + // Detect the deprecated config-nested Foundry agent shape (a populated + // `config:` on a host: azure.ai.agent service). The unified shape carries + // the agent config as service-level properties instead. + if svcConfig.Host == "azure.ai.agent" && len(svcConfig.Config) > 0 { + legacyAgentConfig = true + } } slices.Sort(hosts) @@ -242,6 +249,9 @@ func Load(ctx context.Context, projectFilePath string) (*ProjectConfig, error) { tracing.SetUsageAttributes(fields.ProjectServiceLanguagesKey.StringSlice(languages)) tracing.SetUsageAttributes(fields.ProjectServiceHostsKey.StringSlice(hosts)) + if legacyAgentConfig { + tracing.SetUsageAttributes(fields.FoundryAgentLegacyConfigKey.Bool(true)) + } } return projectConfig, nil diff --git a/docs/reference/telemetry-data.md b/docs/reference/telemetry-data.md index 2e5d025721f..078365f04a5 100644 --- a/docs/reference/telemetry-data.md +++ b/docs/reference/telemetry-data.md @@ -178,6 +178,7 @@ These are set once at process startup and attached to **every** span. | `project.service.targets` | string[] | ❌ | Resolved deployment targets — see [Service Targets](#service-targets) | | `project.service.languages` | string[] | ❌ | Languages across all services — see [Service Languages](#service-languages) | | `project.service.language` | string | ❌ | Language of specific service being executed — see [Service Languages](#service-languages) | +| `foundry.agent.legacy_config_shape` | bool | ❌ | Set to `true` when a project still uses the deprecated config-nested `host: azure.ai.agent` shape (a populated `config:` block). Tracks migration of Foundry agent projects onto the unified `azure.yaml` shape. | | `platform.type` | string | ❌ | Platform integration (e.g., `aca`, `aks`) | #### Service Targets diff --git a/docs/specs/metrics-audit/feature-telemetry-matrix.md b/docs/specs/metrics-audit/feature-telemetry-matrix.md index 741622751ba..cf3dc7952e0 100644 --- a/docs/specs/metrics-audit/feature-telemetry-matrix.md +++ b/docs/specs/metrics-audit/feature-telemetry-matrix.md @@ -165,3 +165,4 @@ privacy review covers every emission point. | **Agent troubleshoot middleware** | Triggered on command failure when troubleshooting is engaged | `agent.troubleshoot` | Error chain attributes, hashed error fields | Emitted from `cmd/middleware/error.go` | | **Up-graph performance** | `up` (graph execution) | (none — enriches the `up` command span) | `perf.provision_duration_ms`, `perf.deploy_duration_ms`, `perf.total_duration_ms` | Emitted from `internal/cmd/up_graph.go` after the graph completes; provision/deploy durations set only when those phases run | | **VS RPC** | `vs-server` long-running session | `vsrpc.*` (event prefix) | Per-RPC attributes documented in `telemetry-schema.md` | Long-running RPC server for VS integration | +| **Project config load** | Any command that loads `azure.yaml` (`provision`/`deploy`/`up`/`down`/etc.) | (none — enriches the active command span) | `foundry.agent.legacy_config_shape` (bool) | Emitted from `pkg/project/project.go` `Load`; set to `true` only when a `host: azure.ai.agent` service still carries a populated `config:` block (the deprecated config-nested Foundry agent shape). Tracks migration onto the unified `azure.yaml` shape | diff --git a/docs/specs/metrics-audit/telemetry-schema.md b/docs/specs/metrics-audit/telemetry-schema.md index eb1983516bd..aa01dd5fbc5 100644 --- a/docs/specs/metrics-audit/telemetry-schema.md +++ b/docs/specs/metrics-audit/telemetry-schema.md @@ -87,6 +87,7 @@ These are set once at process startup via `resource.New()` and attached to every | Service targets | `project.service.targets` | SystemMetadata | FeatureInsight | List of deploy targets | | Service languages | `project.service.languages` | SystemMetadata | FeatureInsight | List of languages | | Service language | `project.service.language` | SystemMetadata | PerformanceAndHealth | Single service language | +| Foundry legacy agent shape | `foundry.agent.legacy_config_shape` | SystemMetadata | FeatureInsight | Bool; `true` when the deprecated config-nested `host: azure.ai.agent` shape is present | | Platform type | `platform.type` | SystemMetadata | FeatureInsight | e.g. `aca`, `aks` | ### Config and Environment diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index cc204c3eef0..631227c0864 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -231,7 +231,12 @@ "aks", "ai.endpoint", "azure.ai.agent", - "microsoft.foundry" + "microsoft.foundry", + "azure.ai.project", + "azure.ai.connection", + "azure.ai.toolbox", + "azure.ai.skill", + "azure.ai.routine" ] }, "language": { @@ -411,7 +416,7 @@ } }, { - "comment": "Azure AI Agent host - requires project, supports docker and config", + "comment": "Azure AI Agent host - agent schema composed at the service level; keeps project/runtime/docker/image, disables config", "if": { "properties": { "host": { "const": "azure.ai.agent" } @@ -419,21 +424,118 @@ }, "then": { "required": ["project"], + "allOf": [ + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json" } + ], "properties": { - "config": { - "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json", - "title": "The Azure AI Agent configuration.", - "description": "Optional. Provides additional configuration for Azure AI Agent deployment." - }, - "image": false, + "config": false, "k8s": false, - "apiVersion": false, - "env": false + "apiVersion": false + } + } + }, + { + "comment": "Azure AI Foundry project host - code-less resource service; composes the project schema at the service level and disables source/container properties", + "if": { + "properties": { + "host": { "const": "azure.ai.project" } + } + }, + "then": { + "allOf": [ + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json" } + ], + "properties": { + "project": false, + "runtime": false, + "docker": false, + "image": false, + "config": false + } + } + }, + { + "comment": "Azure AI Foundry connection host - code-less resource service; the service key is the connection name", + "if": { + "properties": { + "host": { "const": "azure.ai.connection" } + } + }, + "then": { + "allOf": [ + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json" } + ], + "properties": { + "project": false, + "runtime": false, + "docker": false, + "image": false, + "config": false + } + } + }, + { + "comment": "Azure AI Foundry toolbox host - code-less resource service; the service key is the toolbox name", + "if": { + "properties": { + "host": { "const": "azure.ai.toolbox" } + } + }, + "then": { + "allOf": [ + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json" } + ], + "properties": { + "project": false, + "runtime": false, + "docker": false, + "image": false, + "config": false + } + } + }, + { + "comment": "Azure AI Foundry skill host - code-less resource service; the service key is the skill name", + "if": { + "properties": { + "host": { "const": "azure.ai.skill" } + } + }, + "then": { + "allOf": [ + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json" } + ], + "properties": { + "project": false, + "runtime": false, + "docker": false, + "image": false, + "config": false + } + } + }, + { + "comment": "Azure AI Foundry routine host - code-less resource service; the service key is the routine name and it uses: the agent it invokes", + "if": { + "properties": { + "host": { "const": "azure.ai.routine" } + } + }, + "then": { + "allOf": [ + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json" } + ], + "properties": { + "project": false, + "runtime": false, + "docker": false, + "image": false, + "config": false } } }, { - "comment": "Microsoft Foundry host - project config composed at the service level", + "comment": "Legacy Microsoft Foundry host - deprecated compatibility alias for azure.ai.agent provisioning shape", "if": { "properties": { "host": { "const": "microsoft.foundry" } @@ -441,7 +543,7 @@ }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json" } + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json" } ], "properties": { "project": false, @@ -1097,7 +1199,7 @@ "tag": { "type": "string", "title": "The tag that will be applied to the built container image.", - "description": "If omitted, will default to 'azd-deploy-{unix time (seconds)}'. Supports environment variable substitution. For example, to generate unique tags for a given release: myapp/myimage:${DOCKER_IMAGE_TAG}" + "description": "If omitted, will default to 'azd-deploy-{unix time (seconds)}'. Supports environment variable substitution. For example, to generate unique tags for a given release: myapp/image:${DOCKER_IMAGE_TAG}" }, "buildArgs": { "type": "array", diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 03961e907e7..5c9a5394ccf 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -192,7 +192,12 @@ "aks", "ai.endpoint", "azure.ai.agent", - "microsoft.foundry" + "microsoft.foundry", + "azure.ai.project", + "azure.ai.connection", + "azure.ai.toolbox", + "azure.ai.skill", + "azure.ai.routine" ] }, "language": { @@ -371,7 +376,7 @@ } }, { - "comment": "Azure AI Agent host - requires project, supports docker and config", + "comment": "Azure AI Agent host - agent schema composed at the service level; keeps project/runtime/docker/image, disables config", "if": { "properties": { "host": { "const": "azure.ai.agent" } @@ -379,21 +384,118 @@ }, "then": { "required": ["project"], + "allOf": [ + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json" } + ], "properties": { - "config": { - "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json", - "title": "The Azure AI Agent configuration.", - "description": "Optional. Provides additional configuration for Azure AI Agent deployment." - }, - "image": false, + "config": false, "k8s": false, - "apiVersion": false, - "env": false + "apiVersion": false + } + } + }, + { + "comment": "Azure AI Foundry project host - code-less resource service; composes the project schema at the service level and disables source/container properties", + "if": { + "properties": { + "host": { "const": "azure.ai.project" } + } + }, + "then": { + "allOf": [ + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json" } + ], + "properties": { + "project": false, + "runtime": false, + "docker": false, + "image": false, + "config": false + } + } + }, + { + "comment": "Azure AI Foundry connection host - code-less resource service; the service key is the connection name", + "if": { + "properties": { + "host": { "const": "azure.ai.connection" } + } + }, + "then": { + "allOf": [ + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json" } + ], + "properties": { + "project": false, + "runtime": false, + "docker": false, + "image": false, + "config": false + } + } + }, + { + "comment": "Azure AI Foundry toolbox host - code-less resource service; the service key is the toolbox name", + "if": { + "properties": { + "host": { "const": "azure.ai.toolbox" } + } + }, + "then": { + "allOf": [ + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json" } + ], + "properties": { + "project": false, + "runtime": false, + "docker": false, + "image": false, + "config": false + } + } + }, + { + "comment": "Azure AI Foundry skill host - code-less resource service; the service key is the skill name", + "if": { + "properties": { + "host": { "const": "azure.ai.skill" } + } + }, + "then": { + "allOf": [ + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json" } + ], + "properties": { + "project": false, + "runtime": false, + "docker": false, + "image": false, + "config": false + } + } + }, + { + "comment": "Azure AI Foundry routine host - code-less resource service; the service key is the routine name and it uses: the agent it invokes", + "if": { + "properties": { + "host": { "const": "azure.ai.routine" } + } + }, + "then": { + "allOf": [ + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json" } + ], + "properties": { + "project": false, + "runtime": false, + "docker": false, + "image": false, + "config": false } } }, { - "comment": "Microsoft Foundry host - project config composed at the service level", + "comment": "Legacy Microsoft Foundry host - deprecated compatibility alias for azure.ai.agent provisioning shape", "if": { "properties": { "host": { "const": "microsoft.foundry" } @@ -401,7 +503,7 @@ }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json" } + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json" } ], "properties": { "project": false, @@ -1057,7 +1159,7 @@ "tag": { "type": "string", "title": "The tag that will be applied to the built container image.", - "description": "If omitted, will default to 'azd-deploy-{unix time (seconds)}'. Supports environment variable substitution. For example, to generate unique tags for a given release: myapp/myimage:${DOCKER_IMAGE_TAG}" + "description": "If omitted, will default to 'azd-deploy-{unix time (seconds)}'. Supports environment variable substitution. For example, to generate unique tags for a given release: myapp/image:${DOCKER_IMAGE_TAG}" }, "buildArgs": { "type": "array",