From 8c53106bd1ee6cbfd653cd4bfea28b6a5bd316e2 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 12 Jun 2026 17:11:51 +0800 Subject: [PATCH 01/50] feat(agents): add microsoft.foundry service target for a single hosted agent --- .../extensions/azure.ai.agents/extension.yaml | 3 + .../azure.ai.agents/internal/cmd/listen.go | 3 + .../internal/project/foundry_config.go | 371 +++++++++++++++++ .../internal/project/foundry_config_test.go | 300 ++++++++++++++ .../internal/project/service_target_agent.go | 131 +++--- .../project/service_target_foundry.go | 386 ++++++++++++++++++ 6 files changed, 1138 insertions(+), 56 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/foundry_config.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/foundry_config_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index d307ea6b043..32a320cb57d 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -21,6 +21,9 @@ providers: - name: azure.ai.agent type: service-target description: Deploys agents to the Foundry Agent Service + - name: microsoft.foundry + type: service-target + description: Deploys a Foundry project and its agents from a unified azure.yaml service examples: - name: init description: Initialize a new AI agent project. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index f075702deed..39d7ddf1679 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -39,6 +39,9 @@ func configureExtensionHost(host *azdext.ExtensionHost) { WithServiceTarget(AiAgentHost, func() azdext.ServiceTargetProvider { return project.NewAgentServiceTargetProvider(azdClient) }). + WithServiceTarget(project.FoundryHost, func() azdext.ServiceTargetProvider { + return project.NewFoundryServiceTargetProvider(azdClient) + }). WithProjectEventHandler("preprovision", func(ctx context.Context, args *azdext.ProjectEventArgs) error { return preprovisionHandler(ctx, azdClient, args) }). diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config.go new file mode 100644 index 00000000000..edcd51ed42e --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config.go @@ -0,0 +1,371 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "fmt" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" +) + +// FoundryHost is the azure.yaml service host kind for a unified Foundry project. +// A single service with this host owns the Foundry project and all of its +// data-plane state (deployments, connections, toolboxes, skills, routines, and +// agents) declared as top-level service properties (design spec #8590 §2.1). +const FoundryHost = "microsoft.foundry" + +// Agent kind discriminators for a FoundryAgent. +const ( + foundryAgentKindHosted = "hosted" + foundryAgentKindPrompt = "prompt" +) + +// FoundryProjectConfig is the typed view of a `host: microsoft.foundry` service +// entry. The keys arrive on ServiceConfig.AdditionalProperties (the inline map +// captured by yaml:",inline" in azd core) rather than under `config:`. +// +// Only `endpoint` and `agents` are typed here; the remaining project-scoped +// arrays are retained as raw maps because this foundation does not yet reconcile +// them (deferred to the data-plane reconcile work). They are kept on the struct +// so binding does not silently drop them. +type FoundryProjectConfig struct { + Endpoint string `json:"endpoint,omitempty"` + Deployments []map[string]any `json:"deployments,omitempty"` + Connections []map[string]any `json:"connections,omitempty"` + Toolboxes []map[string]any `json:"toolboxes,omitempty"` + Skills []map[string]any `json:"skills,omitempty"` + Routines []map[string]any `json:"routines,omitempty"` + Agents []FoundryAgent `json:"agents,omitempty"` +} + +// FoundryAgent is the union of a hosted agent and a prompt agent, matching +// Agent.json. A hosted agent carries exactly one deploy mode (`docker`, +// `runtime`, or a prebuilt `image`); a prompt agent carries `instructions`. +type FoundryAgent struct { + // Ref holds a `$ref` file include. Resolving includes is deferred to the + // $ref resolver work (#8627); this foundation rejects unresolved refs. + Ref string `json:"$ref,omitempty"` + + Name string `json:"name,omitempty"` + Kind string `json:"kind,omitempty"` + Description string `json:"description,omitempty"` + Env map[string]string `json:"env,omitempty"` + Toolboxes []string `json:"toolboxes,omitempty"` + Tools []map[string]any `json:"tools,omitempty"` + Skill string `json:"skill,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + + // Hosted-agent fields. + Protocols []AgentProtocol `json:"protocols,omitempty"` + Project string `json:"project,omitempty"` + Image string `json:"image,omitempty"` + Docker *AgentDocker `json:"docker,omitempty"` + Runtime *AgentRuntime `json:"runtime,omitempty"` + StartupCommand string `json:"startupCommand,omitempty"` + Container *AgentContainer `json:"container,omitempty"` + + // Prompt-agent fields. + Instructions string `json:"instructions,omitempty"` +} + +// AgentProtocol is a single protocol/version pair a hosted agent implements. +type AgentProtocol struct { + Protocol string `json:"protocol"` + Version string `json:"version"` +} + +// AgentDocker holds container build options for a hosted agent (container mode). +type AgentDocker struct { + Path string `json:"path,omitempty"` + RemoteBuild bool `json:"remoteBuild,omitempty"` +} + +// AgentRuntime holds the code-deploy runtime stack for a hosted agent. +type AgentRuntime struct { + Stack string `json:"stack,omitempty"` + Version string `json:"version,omitempty"` + RemoteBuild bool `json:"remoteBuild,omitempty"` +} + +// AgentContainer holds container runtime settings (CPU/memory) for a hosted agent. +type AgentContainer struct { + Resources *ResourceSettings `json:"resources,omitempty"` +} + +// deployMode identifies how a hosted agent is built and deployed. +type deployMode int + +const ( + deployModeNone deployMode = iota + deployModeImage + deployModeRuntime + deployModeDocker +) + +// deployMode reports the single deploy mode declared on a hosted agent. A hosted +// agent must declare exactly one of `image`, `runtime`, or `docker`; validation +// (see validateHostedAgent) rejects zero or more than one. +func (a FoundryAgent) deployMode() deployMode { + switch { + case a.Docker != nil: + return deployModeDocker + case a.Runtime != nil: + return deployModeRuntime + case a.Image != "": + return deployModeImage + default: + return deployModeNone + } +} + +// modeCount returns how many deploy modes are declared, used to enforce mutual +// exclusivity. +func (a FoundryAgent) modeCount() int { + count := 0 + if a.Docker != nil { + count++ + } + if a.Runtime != nil { + count++ + } + if a.Image != "" { + count++ + } + return count +} + +// Validate checks the Foundry project config for the subset this foundation +// supports: a single hosted agent with exactly one deploy mode. Multi-agent +// fan-out, prompt agents, and data-plane reconcile are intentionally out of +// scope and rejected with actionable errors. +func (c *FoundryProjectConfig) Validate() (FoundryAgent, error) { + if len(c.Agents) == 0 { + return FoundryAgent{}, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "no agents defined on the microsoft.foundry service", + "add an agent under the service 'agents:' array in azure.yaml", + ) + } + + if len(c.Agents) > 1 { + return FoundryAgent{}, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("the microsoft.foundry service declares %d agents; "+ + "multiple agents per service are not yet supported", len(c.Agents)), + "declare a single agent in 'agents:' for now; multi-agent fan-out is coming in a later release", + ) + } + + agent := c.Agents[0] + if agent.Ref != "" { + return FoundryAgent{}, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "agents declared via '$ref' are not yet supported", + "inline the agent definition under 'agents:' in azure.yaml", + ) + } + + if err := validateAgent(agent); err != nil { + return FoundryAgent{}, err + } + + return agent, nil +} + +// validateAgent validates a single agent's kind and deploy mode. +func validateAgent(agent FoundryAgent) error { + if agent.Name == "" { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "agent is missing a 'name'", + "set a 'name' on the agent in azure.yaml", + ) + } + + switch agent.Kind { + case foundryAgentKindHosted: + return validateHostedAgent(agent) + case foundryAgentKindPrompt: + return exterrors.Validation( + exterrors.CodeUnsupportedAgentKind, + "prompt agents are not yet supported by the microsoft.foundry service target", + "use a hosted agent (kind: hosted) for now", + ) + case "": + return exterrors.Validation( + exterrors.CodeMissingAgentKind, + fmt.Sprintf("agent %q is missing a 'kind'", agent.Name), + "set 'kind: hosted' on the agent in azure.yaml", + ) + default: + return exterrors.Validation( + exterrors.CodeUnsupportedAgentKind, + fmt.Sprintf("agent %q has unsupported kind %q", agent.Name, agent.Kind), + "use a supported kind: 'hosted'", + ) + } +} + +// validateHostedAgent enforces exactly one deploy mode and the project +// requirement for build-based modes. +func validateHostedAgent(agent FoundryAgent) error { + switch n := agent.modeCount(); { + case n == 0: + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("hosted agent %q has no deploy mode", agent.Name), + "set exactly one of 'image', 'runtime', or 'docker' on the agent", + ) + case n > 1: + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("hosted agent %q declares more than one deploy mode", agent.Name), + "set exactly one of 'image', 'runtime', or 'docker' on the agent", + ) + } + + switch agent.deployMode() { + case deployModeRuntime: + if agent.Project == "" { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("hosted agent %q sets 'runtime' but is missing 'project'", agent.Name), + "set 'project' to the agent source directory (relative to azure.yaml)", + ) + } + switch agent.Runtime.Stack { + case "python", "dotnet": + // supported by the code-deploy packaging + runtime command path + case "": + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("hosted agent %q sets 'runtime' but is missing 'stack'", agent.Name), + "set 'runtime.stack' to 'python' or 'dotnet'", + ) + default: + return exterrors.Validation( + exterrors.CodeUnsupportedAgentKind, + fmt.Sprintf("hosted agent %q uses runtime stack %q, which is not supported yet", + agent.Name, agent.Runtime.Stack), + "use a 'python' or 'dotnet' runtime stack, or a prebuilt 'image', for now", + ) + } + if agent.StartupCommand == "" { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("hosted agent %q sets 'runtime' but is missing 'startupCommand'", agent.Name), + "set 'startupCommand' (e.g., 'python main.py') so the entry point can be resolved", + ) + } + case deployModeDocker: + return exterrors.Validation( + exterrors.CodeUnsupportedAgentKind, + fmt.Sprintf("hosted agent %q uses 'docker' build, which the microsoft.foundry "+ + "service target does not support yet", agent.Name), + "use a prebuilt 'image' or a code-deploy 'runtime' for now; "+ + "container builds land with per-agent build support", + ) + } + + return nil +} + +// toContainerAgent converts a validated hosted FoundryAgent into the +// agent_yaml.ContainerAgent shape the existing deploy machinery consumes, so the +// CreateAgentVersion request can be built with agent_yaml.CreateAgentAPIRequestFromDefinition. +func (a FoundryAgent) toContainerAgent() (agent_yaml.ContainerAgent, error) { + ca := agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindHosted, + Name: a.Name, + }, + Image: a.Image, + } + + if a.Description != "" { + desc := a.Description + ca.Description = &desc + } + if len(a.Metadata) > 0 { + meta := a.Metadata + ca.Metadata = &meta + } + + for _, p := range a.Protocols { + ca.Protocols = append(ca.Protocols, agent_yaml.ProtocolVersionRecord{ + Protocol: p.Protocol, + Version: p.Version, + }) + } + + if a.deployMode() == deployModeRuntime { + entryPoint, err := a.codeEntryPoint() + if err != nil { + return agent_yaml.ContainerAgent{}, err + } + ca.CodeConfiguration = &agent_yaml.CodeConfiguration{ + Runtime: runtimeString(a.Runtime), + EntryPoint: entryPoint, + } + } + + return ca, nil +} + +// runtimeString maps the typed runtime block to the runtime identifier the +// Foundry API expects, e.g. {stack: python, version: "3.13"} -> "python_3_13". +func runtimeString(rt *AgentRuntime) string { + if rt == nil { + return "" + } + if rt.Version == "" { + return rt.Stack + } + return fmt.Sprintf("%s_%s", rt.Stack, strings.ReplaceAll(rt.Version, ".", "_")) +} + +// codeEntryPoint derives the code-deploy entry point from startupCommand by +// stripping a leading runtime command prefix (e.g. "python main.py" -> "main.py"). +// +// The Foundry agent schema models code-deploy entry via startupCommand rather +// than an explicit entryPoint field; this derivation is the documented seam if +// the schema later adds an explicit field. +func (a FoundryAgent) codeEntryPoint() (string, error) { + fields := strings.Fields(a.StartupCommand) + if len(fields) == 0 { + return "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("hosted agent %q has an empty 'startupCommand'", a.Name), + "set 'startupCommand' (e.g., 'python main.py')", + ) + } + + prefix := agent_yaml.RuntimeCmdPrefix(runtimeString(a.Runtime)) + if len(fields) > 1 && fields[0] == prefix { + return strings.Join(fields[1:], " "), nil + } + + // No recognizable prefix: treat the whole command as the entry point. + return strings.Join(fields, " "), nil +} + +// resolvedEnv expands the agent's env values, resolving azd ${VAR} references via +// the supplied environment while preserving Foundry ${{...}} expressions verbatim +// (design spec §2.5, shared ExpandEnv helper). +func (a FoundryAgent) resolvedEnv(azdEnv map[string]string) map[string]string { + if len(a.Env) == 0 { + return nil + } + resolved := make(map[string]string, len(a.Env)) + for k, v := range a.Env { + expanded, err := ExpandEnv(v, func(name string) string { return azdEnv[name] }) + if err != nil { + expanded = v + } + resolved[k] = expanded + } + return resolved +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config_test.go new file mode 100644 index 00000000000..45e9f6cd18a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config_test.go @@ -0,0 +1,300 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestFoundryProjectConfig_Validate(t *testing.T) { + hostedImage := FoundryAgent{Name: "a", Kind: "hosted", Image: "reg.azurecr.io/a:1"} + hostedRuntime := FoundryAgent{ + Name: "a", + Kind: "hosted", + Project: "src/a", + Runtime: &AgentRuntime{Stack: "python", Version: "3.13"}, + StartupCommand: "python main.py", + } + + tests := []struct { + name string + config FoundryProjectConfig + wantErr bool + errSubstr string + }{ + {name: "valid image", config: FoundryProjectConfig{Agents: []FoundryAgent{hostedImage}}}, + {name: "valid runtime", config: FoundryProjectConfig{Agents: []FoundryAgent{hostedRuntime}}}, + {name: "no agents", config: FoundryProjectConfig{}, wantErr: true, errSubstr: "no agents"}, + { + name: "multiple agents", + config: FoundryProjectConfig{Agents: []FoundryAgent{hostedImage, hostedRuntime}}, + wantErr: true, + errSubstr: "multiple agents", + }, + { + name: "ref agent", + config: FoundryProjectConfig{Agents: []FoundryAgent{{Ref: "./a.yaml"}}}, + wantErr: true, + errSubstr: "$ref", + }, + { + name: "missing name", + config: FoundryProjectConfig{Agents: []FoundryAgent{{Kind: "hosted", Image: "x"}}}, + wantErr: true, + errSubstr: "name", + }, + { + name: "missing kind", + config: FoundryProjectConfig{Agents: []FoundryAgent{{Name: "a", Image: "x"}}}, + wantErr: true, + errSubstr: "kind", + }, + { + name: "prompt unsupported", + config: FoundryProjectConfig{Agents: []FoundryAgent{{Name: "a", Kind: "prompt", Instructions: "hi"}}}, + wantErr: true, + errSubstr: "prompt", + }, + { + name: "unknown kind", + config: FoundryProjectConfig{Agents: []FoundryAgent{{Name: "a", Kind: "wat"}}}, + wantErr: true, + errSubstr: "unsupported kind", + }, + { + name: "hosted no deploy mode", + config: FoundryProjectConfig{Agents: []FoundryAgent{{Name: "a", Kind: "hosted"}}}, + wantErr: true, + errSubstr: "no deploy mode", + }, + { + name: "hosted multiple deploy modes", + config: FoundryProjectConfig{Agents: []FoundryAgent{{ + Name: "a", Kind: "hosted", Image: "x", Runtime: &AgentRuntime{Stack: "python"}, + }}}, + wantErr: true, + errSubstr: "more than one deploy mode", + }, + { + name: "runtime missing project", + config: FoundryProjectConfig{Agents: []FoundryAgent{{ + Name: "a", Kind: "hosted", Runtime: &AgentRuntime{Stack: "python", Version: "3.13"}, StartupCommand: "python main.py", + }}}, + wantErr: true, + errSubstr: "project", + }, + { + name: "runtime missing startupCommand", + config: FoundryProjectConfig{Agents: []FoundryAgent{{ + Name: "a", Kind: "hosted", Project: "src/a", Runtime: &AgentRuntime{Stack: "python", Version: "3.13"}, + }}}, + wantErr: true, + errSubstr: "startupCommand", + }, + { + name: "runtime unsupported stack", + config: FoundryProjectConfig{Agents: []FoundryAgent{{ + Name: "a", Kind: "hosted", Project: "src/a", + Runtime: &AgentRuntime{Stack: "node", Version: "20"}, StartupCommand: "node index.js", + }}}, + wantErr: true, + errSubstr: "not supported", + }, + { + name: "docker unsupported", + config: FoundryProjectConfig{Agents: []FoundryAgent{{ + Name: "a", Kind: "hosted", Project: "src/a", Docker: &AgentDocker{Path: "Dockerfile"}, + }}}, + wantErr: true, + errSubstr: "does not support yet", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agent, err := tt.config.Validate() + if tt.wantErr { + require.Error(t, err) + if tt.errSubstr != "" { + assert.Contains(t, err.Error(), tt.errSubstr) + } + return + } + require.NoError(t, err) + assert.Equal(t, "a", agent.Name) + }) + } +} + +func TestFoundryAgent_toContainerAgent_Image(t *testing.T) { + desc := "an agent" + agent := FoundryAgent{ + Name: "support", + Kind: "hosted", + Description: desc, + Image: "reg.azurecr.io/support:1", + Protocols: []AgentProtocol{{Protocol: "responses", Version: "1.0.0"}}, + Metadata: map[string]any{"team": "cx"}, + } + + ca, err := agent.toContainerAgent() + require.NoError(t, err) + + assert.Equal(t, agent_yaml.AgentKindHosted, ca.Kind) + assert.Equal(t, "support", ca.Name) + assert.Equal(t, "reg.azurecr.io/support:1", ca.Image) + require.NotNil(t, ca.Description) + assert.Equal(t, desc, *ca.Description) + assert.Nil(t, ca.CodeConfiguration) + require.Len(t, ca.Protocols, 1) + assert.Equal(t, "responses", ca.Protocols[0].Protocol) + require.NotNil(t, ca.Metadata) + assert.Equal(t, "cx", (*ca.Metadata)["team"]) +} + +func TestFoundryAgent_toContainerAgent_Runtime(t *testing.T) { + agent := FoundryAgent{ + Name: "code", + Kind: "hosted", + Project: "src/code", + Runtime: &AgentRuntime{Stack: "python", Version: "3.13"}, + StartupCommand: "python main.py", + } + + ca, err := agent.toContainerAgent() + require.NoError(t, err) + + require.NotNil(t, ca.CodeConfiguration) + assert.Equal(t, "python_3_13", ca.CodeConfiguration.Runtime) + assert.Equal(t, "main.py", ca.CodeConfiguration.EntryPoint) + assert.Empty(t, ca.Image) +} + +func TestRuntimeString(t *testing.T) { + assert.Equal(t, "python_3_13", runtimeString(&AgentRuntime{Stack: "python", Version: "3.13"})) + assert.Equal(t, "dotnet_8", runtimeString(&AgentRuntime{Stack: "dotnet", Version: "8"})) + assert.Equal(t, "python", runtimeString(&AgentRuntime{Stack: "python"})) + assert.Equal(t, "", runtimeString(nil)) +} + +func TestFoundryAgent_codeEntryPoint(t *testing.T) { + tests := []struct { + name string + agent FoundryAgent + want string + wantErr bool + }{ + { + name: "strips python prefix", + agent: FoundryAgent{ + Runtime: &AgentRuntime{Stack: "python", Version: "3.13"}, StartupCommand: "python main.py", + }, + want: "main.py", + }, + { + name: "strips dotnet prefix", + agent: FoundryAgent{ + Runtime: &AgentRuntime{Stack: "dotnet", Version: "8"}, StartupCommand: "dotnet MyAgent.dll", + }, + want: "MyAgent.dll", + }, + { + name: "no prefix keeps command", + agent: FoundryAgent{ + Runtime: &AgentRuntime{Stack: "python", Version: "3.13"}, StartupCommand: "main.py", + }, + want: "main.py", + }, + { + name: "empty command errors", + agent: FoundryAgent{Runtime: &AgentRuntime{Stack: "python", Version: "3.13"}, StartupCommand: " "}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.agent.codeEntryPoint() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFoundryAgent_resolvedEnv(t *testing.T) { + agent := FoundryAgent{ + Env: map[string]string{ + "PLAIN": "value", + "FROM_AZD": "${MY_VAR}", + "FOUNDRY": "${{connections.x.key}}", + "MIXED": "${MY_VAR}-${{event.body}}", + "UNDEFINED": "${MISSING}", + }, + } + + resolved := agent.resolvedEnv(map[string]string{"MY_VAR": "hello"}) + + assert.Equal(t, "value", resolved["PLAIN"]) + assert.Equal(t, "hello", resolved["FROM_AZD"]) + assert.Equal(t, "${{connections.x.key}}", resolved["FOUNDRY"]) + assert.Equal(t, "hello-${{event.body}}", resolved["MIXED"]) + assert.Equal(t, "", resolved["UNDEFINED"]) +} + +func TestFoundryAgent_resolvedEnv_Empty(t *testing.T) { + assert.Nil(t, FoundryAgent{}.resolvedEnv(nil)) +} + +// TestFoundryProjectConfig_BindFromAdditionalProperties verifies the config binds +// from a structpb.Struct the way core delivers AdditionalProperties over gRPC. +func TestFoundryProjectConfig_BindFromAdditionalProperties(t *testing.T) { + raw := map[string]any{ + "endpoint": "https://acct.services.ai.azure.com/api/projects/p", + "deployments": []any{ + map[string]any{"name": "gpt-4.1-mini"}, + }, + "agents": []any{ + map[string]any{ + "name": "basic-agent", + "kind": "hosted", + "description": "A basic agent.", + "project": "src/basic-agent", + "startupCommand": "python main.py", + "runtime": map[string]any{"stack": "python", "version": "3.13"}, + "protocols": []any{ + map[string]any{"protocol": "responses", "version": "1.0.0"}, + }, + "env": map[string]any{"FOUNDRY_MODEL_DEPLOYMENT_NAME": "gpt-4.1-mini"}, + }, + }, + } + + s, err := structpb.NewStruct(raw) + require.NoError(t, err) + + var config *FoundryProjectConfig + require.NoError(t, UnmarshalStruct(s, &config)) + require.NotNil(t, config) + + assert.Equal(t, "https://acct.services.ai.azure.com/api/projects/p", config.Endpoint) + assert.Len(t, config.Deployments, 1) + + agent, err := config.Validate() + require.NoError(t, err) + assert.Equal(t, "basic-agent", agent.Name) + assert.Equal(t, deployModeRuntime, agent.deployMode()) + require.NotNil(t, agent.Runtime) + assert.Equal(t, "python", agent.Runtime.Stack) + assert.Equal(t, "gpt-4.1-mini", agent.Env["FOUNDRY_MODEL_DEPLOYMENT_NAME"]) +} 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 aa33e2fdffc..a0f38452a7c 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 @@ -203,63 +203,10 @@ func (p *AgentServiceTargetProvider) Initialize(ctx context.Context, serviceConf ) } - // Get and store environment - azdEnvClient := p.azdClient.Environment() - currEnv, err := azdEnvClient.GetCurrent(ctx, nil) - if err != nil { - return exterrors.Dependency( - exterrors.CodeEnvironmentNotFound, - fmt.Sprintf("failed to get current environment: %s", err), - "run 'azd env new' to create an environment", - ) - } - p.env = currEnv.Environment - - // Get subscription ID from environment - resp, err := azdEnvClient.GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: p.env.Name, - Key: "AZURE_SUBSCRIPTION_ID", - }) - if err != nil { - return fmt.Errorf("failed to get AZURE_SUBSCRIPTION_ID: %w", err) - } - - subscriptionId := resp.Value - if subscriptionId == "" { - return exterrors.Dependency( - exterrors.CodeMissingAzureSubscription, - "AZURE_SUBSCRIPTION_ID is required: environment variable was not found in the current azd environment", - "run 'azd env get-values' to verify environment values, or initialize/project-bind "+ - "with 'azd ai agent init --project-id ...'", - ) - } - - // Get the tenant ID - tenantResponse, err := p.azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ - SubscriptionId: subscriptionId, - }) - if err != nil { - return exterrors.Auth( - exterrors.CodeTenantLookupFailed, - fmt.Sprintf("failed to get tenant ID for subscription %s: %s", subscriptionId, err), - "verify your Azure login with 'azd auth login' and that you have access to this subscription", - ) - } - p.tenantId = tenantResponse.TenantId - - // Create Azure credential - cred, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: p.tenantId, - AdditionallyAllowedTenants: []string{"*"}, - }) - if err != nil { - return exterrors.Auth( - exterrors.CodeCredentialCreationFailed, - fmt.Sprintf("failed to create Azure credential: %s", err), - "run 'azd auth login' to authenticate", - ) + // Get and store environment, subscription, tenant, and credential. + if err := p.setupAuth(ctx); err != nil { + return err } - p.credential = cred fmt.Fprintf(os.Stderr, "Project path: %s, Service path: %s\n", proj.Project.Path, fullPath) @@ -326,6 +273,71 @@ func (p *AgentServiceTargetProvider) Initialize(ctx context.Context, serviceConf ) } +// setupAuth resolves the current azd environment, subscription, tenant, and +// developer credential, storing them on the provider. It is shared by the +// azure.ai.agent and microsoft.foundry hosts so both resolve auth identically. +func (p *AgentServiceTargetProvider) setupAuth(ctx context.Context) error { + // Get and store environment + azdEnvClient := p.azdClient.Environment() + currEnv, err := azdEnvClient.GetCurrent(ctx, nil) + if err != nil { + return exterrors.Dependency( + exterrors.CodeEnvironmentNotFound, + fmt.Sprintf("failed to get current environment: %s", err), + "run 'azd env new' to create an environment", + ) + } + p.env = currEnv.Environment + + // Get subscription ID from environment + resp, err := azdEnvClient.GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: p.env.Name, + Key: "AZURE_SUBSCRIPTION_ID", + }) + if err != nil { + return fmt.Errorf("failed to get AZURE_SUBSCRIPTION_ID: %w", err) + } + + subscriptionId := resp.Value + if subscriptionId == "" { + return exterrors.Dependency( + exterrors.CodeMissingAzureSubscription, + "AZURE_SUBSCRIPTION_ID is required: environment variable was not found in the current azd environment", + "run 'azd env get-values' to verify environment values, or initialize/project-bind "+ + "with 'azd ai agent init --project-id ...'", + ) + } + + // Get the tenant ID + tenantResponse, err := p.azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ + SubscriptionId: subscriptionId, + }) + if err != nil { + return exterrors.Auth( + exterrors.CodeTenantLookupFailed, + fmt.Sprintf("failed to get tenant ID for subscription %s: %s", subscriptionId, err), + "verify your Azure login with 'azd auth login' and that you have access to this subscription", + ) + } + p.tenantId = tenantResponse.TenantId + + // Create Azure credential + cred, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: p.tenantId, + AdditionallyAllowedTenants: []string{"*"}, + }) + if err != nil { + return exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create Azure credential: %s", err), + "run 'azd auth login' to authenticate", + ) + } + p.credential = cred + + return nil +} + // getServiceKey converts a service name into a standardized environment variable key format func (p *AgentServiceTargetProvider) getServiceKey(serviceName string) string { serviceKey := strings.ReplaceAll(serviceName, " ", "_") @@ -1423,6 +1435,13 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(ctx context.Context, serv } } + return zipSourceDir(ctx, srcDir) +} + +// zipSourceDir creates a ZIP archive of srcDir honoring .agentignore, writes it to a +// temp file, and computes its SHA-256. It returns the temp file path and SHA-256 hex +// string. Shared by the azure.ai.agent and microsoft.foundry code-deploy packaging paths. +func zipSourceDir(ctx context.Context, srcDir string) (string, string, error) { // Load .agentignore (or use defaults if no file exists) ignoreMatcher, err := newAgentIgnoreMatcher(ctx, srcDir) if err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go new file mode 100644 index 00000000000..0074d334bb9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go @@ -0,0 +1,386 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/paths" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// Ensure FoundryServiceTargetProvider implements the ServiceTargetProvider interface. +var _ azdext.ServiceTargetProvider = &FoundryServiceTargetProvider{} + +// FoundryServiceTargetProvider implements the service target for `host: microsoft.foundry`. +// +// A single service stands for a whole Foundry project. This foundation supports a +// single hosted agent end-to-end (prebuilt image or code-deploy runtime) by mapping +// the inline agent definition onto the existing agent deploy machinery. Multi-agent +// fan-out, container (docker) builds, and data-plane reconcile (deployments, +// connections, toolboxes, skills, routines) are intentionally out of scope here and +// land in follow-up work (design spec #8590 §2.6, §2.8). +type FoundryServiceTargetProvider struct { + azdClient *azdext.AzdClient + + // agent is an AgentServiceTargetProvider reused for shared auth setup and the + // low-level create/poll/finalize helpers, since the deploy primitives are + // identical to the azure.ai.agent host. + agent *AgentServiceTargetProvider + + config *FoundryProjectConfig + hostedAgent FoundryAgent + projectRoot string + serviceConfig *azdext.ServiceConfig + initialized bool +} + +// NewFoundryServiceTargetProvider creates a new FoundryServiceTargetProvider instance. +func NewFoundryServiceTargetProvider(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { + return &FoundryServiceTargetProvider{ + azdClient: azdClient, + agent: &AgentServiceTargetProvider{azdClient: azdClient}, + } +} + +// Initialize binds the inline Foundry configuration, validates the single supported +// hosted agent, resolves the project root, and sets up authentication. +func (p *FoundryServiceTargetProvider) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { + if p.initialized { + return nil + } + + p.serviceConfig = serviceConfig + p.agent.serviceConfig = serviceConfig + + // The Foundry keys are top-level service properties carried on + // AdditionalProperties (not under `config:`), per design spec §2.1. + var config *FoundryProjectConfig + if err := UnmarshalStruct(serviceConfig.AdditionalProperties, &config); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("failed to parse microsoft.foundry service config: %s", err), + "check the Foundry service definition in azure.yaml", + ) + } + if config == nil { + config = &FoundryProjectConfig{} + } + p.config = config + + hostedAgent, err := config.Validate() + if err != nil { + return err + } + p.hostedAgent = hostedAgent + + // Resolve the project root (the directory holding azure.yaml). Agent `project` + // paths resolve relative to it. + proj, err := p.azdClient.Project().Get(ctx, nil) + if err != nil { + return exterrors.Dependency( + exterrors.CodeProjectNotFound, + fmt.Sprintf("failed to get project: %s", err), + "run 'azd init' to initialize your project", + ) + } + p.projectRoot = proj.Project.Path + + // Resolve environment, subscription, tenant, and credential (shared with the + // azure.ai.agent host). + if err := p.agent.setupAuth(ctx); err != nil { + return err + } + + p.initialized = true + return nil +} + +// Endpoints returns the deployed agent's endpoints. Delegates to the shared +// implementation, which reads the per-service AGENT__* environment values +// written during Deploy. +func (p *FoundryServiceTargetProvider) Endpoints( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + targetResource *azdext.TargetResource, +) ([]string, error) { + return p.agent.Endpoints(ctx, serviceConfig, targetResource) +} + +// GetTargetResource resolves the ARM resource for the Foundry project. Delegates to +// the shared implementation, which resolves the project from AZURE_AI_PROJECT_ID. +func (p *FoundryServiceTargetProvider) GetTargetResource( + ctx context.Context, + subscriptionId string, + serviceConfig *azdext.ServiceConfig, + defaultResolver func() (*azdext.TargetResource, error), +) (*azdext.TargetResource, error) { + return p.agent.GetTargetResource(ctx, subscriptionId, serviceConfig, defaultResolver) +} + +// Package builds the deploy artifact for the single hosted agent. Code-deploy +// (runtime) agents are zipped from their `project` directory; prebuilt-image agents +// need no packaging. +func (p *FoundryServiceTargetProvider) Package( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + progress azdext.ProgressReporter, +) (*azdext.ServicePackageResult, error) { + if p.hostedAgent.deployMode() != deployModeRuntime { + progress("Using pre-built container image, skipping package") + return &azdext.ServicePackageResult{}, nil + } + + srcDir, err := paths.JoinAllowRoot(p.projectRoot, p.hostedAgent.Project) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("invalid agent project path %q: %s", p.hostedAgent.Project, err), + "set 'project' to a directory within the project root", + ) + } + + progress("Packaging code") + zipPath, sha256Hex, err := zipSourceDir(ctx, srcDir) + if err != nil { + return nil, exterrors.Internal(exterrors.OpContainerPackage, fmt.Sprintf("code packaging failed: %s", err)) + } + + return &azdext.ServicePackageResult{ + Artifacts: []*azdext.Artifact{ + { + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ARCHIVE, + Location: zipPath, + LocationKind: azdext.LocationKind_LOCATION_KIND_LOCAL, + Metadata: map[string]string{ + "type": "code-zip", + "sha256": sha256Hex, + }, + }, + }, + }, nil +} + +// Publish is a no-op for the supported deploy modes: prebuilt images are already +// remote, and code-deploy uploads its ZIP during Deploy. +func (p *FoundryServiceTargetProvider) 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 posts the single hosted agent to Foundry via CreateAgentVersion (image) or +// a ZIP code deploy (runtime), then polls until the version is active and registers +// the agent's environment values. +func (p *FoundryServiceTargetProvider) Deploy( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + progress azdext.ProgressReporter, +) (*azdext.ServiceDeployResult, error) { + azdEnv, err := p.environmentValues(ctx) + if err != nil { + return nil, err + } + + // An explicit endpoint: on the service points at an existing project; use it as + // the deploy endpoint when provision did not write FOUNDRY_PROJECT_ENDPOINT. + if azdEnv["FOUNDRY_PROJECT_ENDPOINT"] == "" && p.config.Endpoint != "" { + azdEnv["FOUNDRY_PROJECT_ENDPOINT"] = p.config.Endpoint + } + if azdEnv["FOUNDRY_PROJECT_ENDPOINT"] == "" { + return nil, exterrors.Dependency( + exterrors.CodeMissingAiProjectEndpoint, + "FOUNDRY_PROJECT_ENDPOINT is required: environment variable was not found in the current azd environment", + "run 'azd provision', or set 'endpoint:' on the microsoft.foundry service to use an existing project", + ) + } + + request, protocols, err := p.buildAgentRequest(azdEnv) + if err != nil { + return nil, err + } + + var agentVersion *agent_api.AgentVersionObject + if p.hostedAgent.deployMode() == deployModeRuntime { + agentVersion, err = p.deployCodeAgent(ctx, serviceContext, progress, request, azdEnv) + } else { + progress("Creating agent") + agentVersion, err = p.agent.createAgent(ctx, request, azdEnv) + } + if err != nil { + return nil, err + } + + // Poll until the agent version is active. + if agentVersion.Status != "active" { + agentClient := agent_api.NewAgentClient(azdEnv["FOUNDRY_PROJECT_ENDPOINT"], p.agent.credential) + polled, pollErr := p.agent.waitForAgentActive(ctx, agentClient, request.Name, agentVersion.Version, progress) + if pollErr != nil { + return nil, pollErr + } + agentVersion = polled + } else { + fmt.Fprintf(os.Stderr, "Agent version %s is already active.\n", agentVersion.Version) + } + + return p.agent.finalizeDeploy(ctx, progress, serviceConfig, azdEnv, agentVersion, protocols) +} + +// environmentValues returns the current azd environment values as a map. +func (p *FoundryServiceTargetProvider) environmentValues(ctx context.Context) (map[string]string, error) { + resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: p.agent.env.Name, + }) + if err != nil { + return nil, exterrors.Dependency( + exterrors.CodeEnvironmentValuesFailed, + fmt.Sprintf("failed to get environment values: %s", err), + "run 'azd env get-values' to verify environment state", + ) + } + + azdEnv := make(map[string]string, len(resp.KeyValues)) + for _, kval := range resp.KeyValues { + azdEnv[kval.Key] = kval.Value + } + return azdEnv, nil +} + +// buildAgentRequest maps the inline hosted agent onto a CreateAgentRequest, resolving +// env values and defaulting protocols. It returns the request plus the protocol list +// used for endpoint registration. +func (p *FoundryServiceTargetProvider) buildAgentRequest( + azdEnv map[string]string, +) (*agent_api.CreateAgentRequest, []agent_yaml.ProtocolVersionRecord, error) { + containerAgent, err := p.hostedAgent.toContainerAgent() + if err != nil { + return nil, nil, err + } + + // Default to the "responses" protocol when none is specified. + if len(containerAgent.Protocols) == 0 { + containerAgent.Protocols = []agent_yaml.ProtocolVersionRecord{ + {Protocol: string(agent_api.AgentProtocolResponses), Version: "1.0.0"}, + } + } + + options := []agent_yaml.AgentBuildOption{} + if env := p.hostedAgent.resolvedEnv(azdEnv); len(env) > 0 { + options = append(options, agent_yaml.WithEnvironmentVariables(env)) + } + if p.hostedAgent.Container != nil && p.hostedAgent.Container.Resources != nil { + if cpu := p.hostedAgent.Container.Resources.Cpu; cpu != "" { + options = append(options, agent_yaml.WithCPU(cpu)) + } + if memory := p.hostedAgent.Container.Resources.Memory; memory != "" { + options = append(options, agent_yaml.WithMemory(memory)) + } + } + if p.hostedAgent.deployMode() == deployModeImage { + options = append(options, agent_yaml.WithImageURL(p.hostedAgent.Image)) + } + + request, err := agent_yaml.CreateAgentAPIRequestFromDefinition(containerAgent, options...) + if err != nil { + return nil, nil, exterrors.Validation( + exterrors.CodeInvalidAgentRequest, + fmt.Sprintf("failed to build agent request: %s", err), + "verify the agent definition in azure.yaml", + ) + } + applyAgentMetadata(request) + + return request, containerAgent.Protocols, nil +} + +// deployCodeAgent performs a ZIP code deploy for a runtime-mode hosted agent, +// creating the agent when absent and updating it (new version) when present. +func (p *FoundryServiceTargetProvider) deployCodeAgent( + ctx context.Context, + serviceContext *azdext.ServiceContext, + progress azdext.ProgressReporter, + request *agent_api.CreateAgentRequest, + azdEnv map[string]string, +) (*agent_api.AgentVersionObject, error) { + var zipPath, sha256Hex string + for _, artifact := range serviceContext.Package { + if artifact.Metadata != nil && artifact.Metadata["type"] == "code-zip" { + zipPath = artifact.Location + sha256Hex = artifact.Metadata["sha256"] + break + } + } + if zipPath == "" { + return nil, exterrors.Dependency( + exterrors.CodeMissingCodeZipArtifact, + "code ZIP artifact not found: no code-zip artifact was found in service package artifacts", + "run 'azd package' to produce the code ZIP artifact", + ) + } + + zipData, err := os.ReadFile(zipPath) //nolint:gosec // zipPath comes from the artifact location set during packaging + if err != nil { + return nil, fmt.Errorf("failed to read ZIP artifact: %w", err) + } + defer os.Remove(zipPath) + + versionRequest := &agent_api.CreateAgentVersionRequest{ + Description: request.Description, + Metadata: request.Metadata, + Definition: request.Definition, + } + + agentClient := agent_api.NewAgentClient(azdEnv["FOUNDRY_PROJECT_ENDPOINT"], p.agent.credential) + + progress("Creating agent") + _, getErr := agentClient.GetAgent(ctx, request.Name, agent_api.AgentEndpointAPIVersion) + + var agentResp *agent_api.AgentObject + if getErr != nil { + // Only fall back to create on 404; propagate other errors (auth, 5xx, network). + if respErr, ok := errors.AsType[*azcore.ResponseError](getErr); !ok || respErr.StatusCode != http.StatusNotFound { + return nil, fmt.Errorf("failed to check if agent exists: %w", getErr) + } + fmt.Fprintf(os.Stderr, "Creating new agent: %s\n", request.Name) + agentResp, err = agentClient.CreateAgentFromZip( + ctx, request.Name, versionRequest, zipData, sha256Hex, agent_api.AgentEndpointAPIVersion, + ) + if err != nil { + return nil, exterrors.Internal( + exterrors.CodeAgentCreateFailed, + fmt.Sprintf("failed to create agent from ZIP: %s; check the agent definition and try again", err), + ) + } + } else { + writeExistingAgentVersionWarning(request.Name) + agentResp, err = agentClient.UpdateAgentFromZip( + ctx, request.Name, versionRequest, zipData, sha256Hex, agent_api.AgentEndpointAPIVersion, + ) + if err != nil { + return nil, exterrors.Internal( + exterrors.CodeAgentCreateFailed, + fmt.Sprintf("failed to update agent from ZIP: %s; check the agent definition and try again", err), + ) + } + } + + return &agentResp.Versions.Latest, nil +} From 1b8a0eea16ba8aa4a6f912022a77ef39908cd572 Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 15 Jun 2026 11:40:05 +0800 Subject: [PATCH 02/50] feat: resolve Foundry project from endpoint so deploy runs without provision --- .../project/foundry_project_resolve.go | 198 ++++++++++++++++++ .../project/foundry_project_resolve_test.go | 76 +++++++ .../project/service_target_foundry.go | 6 + 3 files changed, 280 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go new file mode 100644 index 00000000000..852ffe1e3dc --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "net/url" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/azure" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// foundryProjectResourceType is the ARM resource type for a Foundry project. +const foundryProjectResourceType = "Microsoft.CognitiveServices/accounts/projects" + +// foundryEndpointHostSuffix is the host suffix of a Foundry project endpoint. +const foundryEndpointHostSuffix = ".services.ai.azure.com" + +// parseFoundryEndpoint extracts the account and project names from a Foundry +// project endpoint of the form +// https://.services.ai.azure.com/api/projects/. +func parseFoundryEndpoint(endpoint string) (account string, project string, err error) { + trimmed := strings.TrimSpace(endpoint) + if trimmed == "" { + return "", "", fmt.Errorf("endpoint is empty") + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return "", "", fmt.Errorf("invalid endpoint %q: %w", endpoint, err) + } + + host := parsed.Hostname() + if !strings.HasSuffix(strings.ToLower(host), foundryEndpointHostSuffix) { + return "", "", fmt.Errorf("endpoint host %q is not a Foundry project endpoint", host) + } + account = host[:len(host)-len(foundryEndpointHostSuffix)] + if account == "" { + return "", "", fmt.Errorf("endpoint %q is missing the account name", endpoint) + } + + // Path is /api/projects/; take the segment after "projects". + segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") + for i := 0; i+1 < len(segments); i++ { + if segments[i] == "projects" && segments[i+1] != "" { + project = segments[i+1] + break + } + } + if project == "" { + return "", "", fmt.Errorf("endpoint %q is missing the project name", endpoint) + } + + return account, project, nil +} + +// resolveFoundryProjectIDFromEndpoint resolves the ARM resource ID of a Foundry +// project from its data-plane endpoint by listing the Foundry projects in the +// subscription and matching the account and project names. It enables the +// `endpoint:` path (design spec #8590 §1.4): connect to an existing project +// without provisioning, so `azd deploy` can run without AZURE_AI_PROJECT_ID. +func resolveFoundryProjectIDFromEndpoint( + ctx context.Context, + credential azcore.TokenCredential, + subscriptionID string, + endpoint string, +) (string, error) { + account, project, err := parseFoundryEndpoint(endpoint) + if err != nil { + return "", err + } + + client, err := armresources.NewClient(subscriptionID, credential, azure.NewArmClientOptions()) + if err != nil { + return "", fmt.Errorf("failed to create resources client: %w", err) + } + + pager := client.NewListPager(&armresources.ClientListOptions{ + Filter: new(fmt.Sprintf("resourceType eq '%s'", foundryProjectResourceType)), + }) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return "", fmt.Errorf("failed to list Foundry projects: %w", err) + } + for _, resource := range page.Value { + if resource == nil || resource.ID == nil { + continue + } + parsed, err := arm.ParseResourceID(*resource.ID) + if err != nil || parsed.Parent == nil { + continue + } + if strings.EqualFold(parsed.Parent.Name, account) && strings.EqualFold(parsed.Name, project) { + return *resource.ID, nil + } + } + } + + return "", fmt.Errorf( + "no Foundry project matching endpoint %q was found in subscription %s", endpoint, subscriptionID) +} + +// resolveProjectFromEndpoint connects to an existing Foundry project when the +// service sets `endpoint:` but no project was provisioned. It resolves the +// project's ARM resource ID from the endpoint and persists it as +// AZURE_AI_PROJECT_ID, so the shared deploy machinery (GetTargetResource, +// finalizeDeploy) works without `azd provision` (design spec #8590 §1.4). +func (p *FoundryServiceTargetProvider) resolveProjectFromEndpoint(ctx context.Context) error { + // Already provisioned or previously resolved: nothing to do. + existing, err := p.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: p.agent.env.Name, + Key: "AZURE_AI_PROJECT_ID", + }) + if err == nil && existing.Value != "" { + return nil + } + + endpoint := p.resolveEndpoint(ctx) + if endpoint == "" { + // No endpoint and no project ID: leave resolution to provision. The + // deploy path surfaces an actionable error if neither is present. + return nil + } + + subscriptionID, err := p.subscriptionID(ctx) + if err != nil { + return err + } + + projectID, err := resolveFoundryProjectIDFromEndpoint(ctx, p.agent.credential, subscriptionID, endpoint) + if err != nil { + return exterrors.Dependency( + exterrors.CodeMissingAiProjectId, + fmt.Sprintf("failed to resolve the Foundry project from endpoint %q: %s", endpoint, err), + "verify the 'endpoint:' on the microsoft.foundry service points at an existing project "+ + "you can access, or run 'azd provision'", + ) + } + + if _, err := p.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: p.agent.env.Name, + Key: "AZURE_AI_PROJECT_ID", + Value: projectID, + }); err != nil { + return fmt.Errorf("failed to persist AZURE_AI_PROJECT_ID: %w", err) + } + + return nil +} + +// resolveEndpoint returns the Foundry project endpoint to connect to, preferring +// the service `endpoint:` field (with ${VAR} expansion, since core does not +// expand AdditionalProperties) and falling back to the FOUNDRY_PROJECT_ENDPOINT +// azd environment value. +func (p *FoundryServiceTargetProvider) resolveEndpoint(ctx context.Context) string { + if p.config != nil && p.config.Endpoint != "" { + azdEnv, _ := p.environmentValues(ctx) + expanded, err := ExpandEnv(p.config.Endpoint, func(name string) string { return azdEnv[name] }) + if err == nil && strings.TrimSpace(expanded) != "" { + return expanded + } + return p.config.Endpoint + } + + resp, err := p.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: p.agent.env.Name, + Key: "FOUNDRY_PROJECT_ENDPOINT", + }) + if err != nil { + return "" + } + return resp.Value +} + +// subscriptionID reads AZURE_SUBSCRIPTION_ID from the active azd environment. +func (p *FoundryServiceTargetProvider) subscriptionID(ctx context.Context) (string, error) { + resp, err := p.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: p.agent.env.Name, + Key: "AZURE_SUBSCRIPTION_ID", + }) + if err != nil || resp.Value == "" { + return "", exterrors.Dependency( + exterrors.CodeMissingAzureSubscription, + "AZURE_SUBSCRIPTION_ID is required to resolve the Foundry project from 'endpoint:'", + "run 'azd env set AZURE_SUBSCRIPTION_ID ' or 'azd provision'", + ) + } + return resp.Value, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve_test.go new file mode 100644 index 00000000000..4a8473fa976 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve_test.go @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import "testing" + +func TestParseFoundryEndpoint(t *testing.T) { + tests := []struct { + name string + endpoint string + wantAccount string + wantProject string + wantErr bool + }{ + { + name: "standard endpoint", + endpoint: "https://my-account.services.ai.azure.com/api/projects/my-project", + wantAccount: "my-account", + wantProject: "my-project", + }, + { + name: "trailing slash", + endpoint: "https://acct.services.ai.azure.com/api/projects/proj/", + wantAccount: "acct", + wantProject: "proj", + }, + { + name: "uppercase host", + endpoint: "https://Acct.Services.AI.Azure.Com/api/projects/Proj", + wantAccount: "Acct", + wantProject: "Proj", + }, + { + name: "empty", + endpoint: "", + wantErr: true, + }, + { + name: "non-foundry host", + endpoint: "https://example.com/api/projects/proj", + wantErr: true, + }, + { + name: "missing project", + endpoint: "https://acct.services.ai.azure.com/api/projects", + wantErr: true, + }, + { + name: "missing project segment", + endpoint: "https://acct.services.ai.azure.com/", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + account, project, err := parseFoundryEndpoint(tt.endpoint) + if tt.wantErr { + if err == nil { + t.Fatalf("parseFoundryEndpoint(%q) expected error, got none", tt.endpoint) + } + return + } + if err != nil { + t.Fatalf("parseFoundryEndpoint(%q) unexpected error: %v", tt.endpoint, err) + } + if account != tt.wantAccount { + t.Errorf("account = %q, want %q", account, tt.wantAccount) + } + if project != tt.wantProject { + t.Errorf("project = %q, want %q", project, tt.wantProject) + } + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go index 0074d334bb9..5bbd9131ff0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go @@ -102,6 +102,12 @@ func (p *FoundryServiceTargetProvider) Initialize(ctx context.Context, serviceCo return err } + // Connect to an existing project via `endpoint:` when no project was + // provisioned, so deploy can run without `azd provision` (spec §1.4). + if err := p.resolveProjectFromEndpoint(ctx); err != nil { + return err + } + p.initialized = true return nil } From f3ac0f7661f0a5090167135234cfd74d8a795de4 Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 15 Jun 2026 22:03:11 +0800 Subject: [PATCH 03/50] fix(agents): expand endpoint vars and use correct error codes in foundry service target --- .../azure.ai.agents/internal/project/foundry_config.go | 4 ++-- .../internal/project/service_target_foundry.go | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config.go index edcd51ed42e..079f66ae465 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config.go @@ -247,7 +247,7 @@ func validateHostedAgent(agent FoundryAgent) error { ) default: return exterrors.Validation( - exterrors.CodeUnsupportedAgentKind, + exterrors.CodeInvalidServiceConfig, fmt.Sprintf("hosted agent %q uses runtime stack %q, which is not supported yet", agent.Name, agent.Runtime.Stack), "use a 'python' or 'dotnet' runtime stack, or a prebuilt 'image', for now", @@ -262,7 +262,7 @@ func validateHostedAgent(agent FoundryAgent) error { } case deployModeDocker: return exterrors.Validation( - exterrors.CodeUnsupportedAgentKind, + exterrors.CodeInvalidServiceConfig, fmt.Sprintf("hosted agent %q uses 'docker' build, which the microsoft.foundry "+ "service target does not support yet", agent.Name), "use a prebuilt 'image' or a code-deploy 'runtime' for now; "+ diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go index 5bbd9131ff0..8e9831b8b57 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go @@ -208,8 +208,13 @@ func (p *FoundryServiceTargetProvider) Deploy( // An explicit endpoint: on the service points at an existing project; use it as // the deploy endpoint when provision did not write FOUNDRY_PROJECT_ENDPOINT. + // Expand ${VAR} references so values like ${MY_ENDPOINT} resolve correctly. if azdEnv["FOUNDRY_PROJECT_ENDPOINT"] == "" && p.config.Endpoint != "" { - azdEnv["FOUNDRY_PROJECT_ENDPOINT"] = p.config.Endpoint + endpoint := p.config.Endpoint + if expanded, err := ExpandEnv(p.config.Endpoint, func(name string) string { return azdEnv[name] }); err == nil && expanded != "" { + endpoint = expanded + } + azdEnv["FOUNDRY_PROJECT_ENDPOINT"] = endpoint } if azdEnv["FOUNDRY_PROJECT_ENDPOINT"] == "" { return nil, exterrors.Dependency( From d6b04b456f78573af6ef6b1a177b7047c2ed1cb6 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 16 Jun 2026 17:38:08 +0800 Subject: [PATCH 04/50] fix(agents): validate foundry project endpoint to enforce https and reject ports --- .../project/foundry_project_resolve.go | 37 ++++++++++++++---- .../project/foundry_project_resolve_test.go | 38 +++++++++++++++++++ .../project/service_target_foundry.go | 12 ++++++ 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go index 852ffe1e3dc..6ebb9cf480e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go @@ -24,24 +24,47 @@ const foundryProjectResourceType = "Microsoft.CognitiveServices/accounts/project // foundryEndpointHostSuffix is the host suffix of a Foundry project endpoint. const foundryEndpointHostSuffix = ".services.ai.azure.com" -// parseFoundryEndpoint extracts the account and project names from a Foundry -// project endpoint of the form -// https://.services.ai.azure.com/api/projects/. -func parseFoundryEndpoint(endpoint string) (account string, project string, err error) { +// validateFoundryEndpoint enforces the transport rules every Foundry data-plane +// caller relies on: a non-empty https URL on a recognized Foundry host with no +// explicit port. Rejecting http, foreign hosts, and ports up front avoids +// sending credentials to an unexpected endpoint and catches a partially +// expanded ${VAR} that would otherwise leave an invalid host. It returns the +// parsed URL so callers can extract additional structure without re-parsing. +func validateFoundryEndpoint(endpoint string) (*url.URL, error) { trimmed := strings.TrimSpace(endpoint) if trimmed == "" { - return "", "", fmt.Errorf("endpoint is empty") + return nil, fmt.Errorf("endpoint is empty") } parsed, err := url.Parse(trimmed) if err != nil { - return "", "", fmt.Errorf("invalid endpoint %q: %w", endpoint, err) + return nil, fmt.Errorf("invalid endpoint %q: %w", endpoint, err) + } + if !strings.EqualFold(parsed.Scheme, "https") { + return nil, fmt.Errorf("endpoint %q must use https", endpoint) } host := parsed.Hostname() if !strings.HasSuffix(strings.ToLower(host), foundryEndpointHostSuffix) { - return "", "", fmt.Errorf("endpoint host %q is not a Foundry project endpoint", host) + return nil, fmt.Errorf("endpoint host %q is not a Foundry project endpoint", host) } + if parsed.Port() != "" { + return nil, fmt.Errorf("endpoint %q must not include a port", endpoint) + } + + return parsed, nil +} + +// parseFoundryEndpoint extracts the account and project names from a Foundry +// project endpoint of the form +// https://.services.ai.azure.com/api/projects/. +func parseFoundryEndpoint(endpoint string) (account string, project string, err error) { + parsed, err := validateFoundryEndpoint(endpoint) + if err != nil { + return "", "", err + } + + host := parsed.Hostname() account = host[:len(host)-len(foundryEndpointHostSuffix)] if account == "" { return "", "", fmt.Errorf("endpoint %q is missing the account name", endpoint) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve_test.go index 4a8473fa976..f57be5cb9ab 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve_test.go @@ -51,6 +51,16 @@ func TestParseFoundryEndpoint(t *testing.T) { endpoint: "https://acct.services.ai.azure.com/", wantErr: true, }, + { + name: "http scheme rejected", + endpoint: "http://acct.services.ai.azure.com/api/projects/proj", + wantErr: true, + }, + { + name: "explicit port rejected", + endpoint: "https://acct.services.ai.azure.com:443/api/projects/proj", + wantErr: true, + }, } for _, tt := range tests { @@ -74,3 +84,31 @@ func TestParseFoundryEndpoint(t *testing.T) { }) } } + +func TestValidateFoundryEndpoint(t *testing.T) { + tests := []struct { + name string + endpoint string + wantErr bool + }{ + {name: "valid project endpoint", endpoint: "https://acct.services.ai.azure.com/api/projects/proj"}, + {name: "valid without path", endpoint: "https://acct.services.ai.azure.com"}, + {name: "empty", endpoint: "", wantErr: true}, + {name: "http scheme", endpoint: "http://acct.services.ai.azure.com", wantErr: true}, + {name: "foreign host", endpoint: "https://evil.example.com", wantErr: true}, + {name: "explicit port", endpoint: "https://acct.services.ai.azure.com:8443", wantErr: true}, + {name: "partially expanded var", endpoint: "https://${ACCOUNT}/api/projects/proj", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := validateFoundryEndpoint(tt.endpoint) + if tt.wantErr && err == nil { + t.Fatalf("validateFoundryEndpoint(%q) expected error, got none", tt.endpoint) + } + if !tt.wantErr && err != nil { + t.Fatalf("validateFoundryEndpoint(%q) unexpected error: %v", tt.endpoint, err) + } + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go index 8e9831b8b57..8398883e7ee 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go @@ -224,6 +224,18 @@ func (p *FoundryServiceTargetProvider) Deploy( ) } + // Reject an insecure or non-Foundry endpoint (http, foreign host, explicit + // port, or a partially expanded ${VAR}) before using it to construct an + // authenticated AgentClient. + if _, err := validateFoundryEndpoint(azdEnv["FOUNDRY_PROJECT_ENDPOINT"]); err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("FOUNDRY_PROJECT_ENDPOINT is not a valid Foundry project endpoint: %v", err), + "set 'endpoint:' (or FOUNDRY_PROJECT_ENDPOINT) to an https Foundry project URL, "+ + "e.g. https://.services.ai.azure.com/api/projects/", + ) + } + request, protocols, err := p.buildAgentRequest(azdEnv) if err != nil { return nil, err From 4dfe0c2fe2920af95fe6cf4b8a269e365900ac6f Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 17 Jun 2026 16:39:23 +0800 Subject: [PATCH 05/50] fix(foundry): persist FOUNDRY_PROJECT_ENDPOINT in endpoint-only flow --- .../internal/project/foundry_project_resolve.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go index 6ebb9cf480e..68978e0ff98 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go @@ -177,6 +177,17 @@ func (p *FoundryServiceTargetProvider) resolveProjectFromEndpoint(ctx context.Co return fmt.Errorf("failed to persist AZURE_AI_PROJECT_ID: %w", err) } + // Persist the resolved endpoint so that Endpoints() and azd show work after a + // deploy without provision. Without this, FOUNDRY_PROJECT_ENDPOINT is only set + // in-memory during Deploy and is absent from the env on the next command. + if _, err := p.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: p.agent.env.Name, + Key: "FOUNDRY_PROJECT_ENDPOINT", + Value: endpoint, + }); err != nil { + return fmt.Errorf("failed to persist FOUNDRY_PROJECT_ENDPOINT: %w", err) + } + return nil } From 08fad8dcf40f1250889dff2037f4d68b800c4209 Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 22 Jun 2026 13:04:06 +0800 Subject: [PATCH 06/50] feat: split Foundry init into per-resource azure.yaml services --- .../extensions/azure.ai.agents/CHANGELOG.md | 6 + .../extensions/azure.ai.agents/extension.yaml | 2 +- .../azure.ai.agents/internal/cmd/init.go | 22 ++ .../internal/cmd/init_from_code.go | 14 + .../azure.ai.agents/internal/cmd/listen.go | 109 +++++-- .../internal/cmd/resource_services.go | 274 ++++++++++++++++++ .../internal/cmd/resource_services_test.go | 158 ++++++++++ .../extensions/azure.ai.agents/version.txt | 2 +- .../azure.ai.connections/CHANGELOG.md | 4 + .../azure.ai.connections/extension.yaml | 7 +- .../internal/cmd/listen.go | 23 ++ .../azure.ai.connections/internal/cmd/root.go | 4 + .../internal/project/service_target.go | 104 +++++++ .../azure.ai.connections/version.txt | 2 +- .../extensions/azure.ai.projects/CHANGELOG.md | 6 + .../azure.ai.projects/extension.yaml | 7 +- .../azure.ai.projects/internal/cmd/listen.go | 23 ++ .../azure.ai.projects/internal/cmd/root.go | 4 + .../internal/project/service_target.go | 103 +++++++ .../extensions/azure.ai.projects/version.txt | 2 +- .../azure.ai.toolboxes/CHANGELOG.md | 4 + .../azure.ai.toolboxes/extension.yaml | 7 +- .../azure.ai.toolboxes/internal/cmd/listen.go | 23 ++ .../azure.ai.toolboxes/internal/cmd/root.go | 4 + .../internal/project/service_target.go | 104 +++++++ .../extensions/azure.ai.toolboxes/version.txt | 2 +- 26 files changed, 982 insertions(+), 38 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go create mode 100644 cli/azd/extensions/azure.ai.connections/internal/cmd/listen.go create mode 100644 cli/azd/extensions/azure.ai.connections/internal/project/service_target.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/listen.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/project/service_target.go create mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/listen.go create mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/project/service_target.go diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index f63bf0bda84..c8bf7a344df 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## Unreleased + +### 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:`. Provisioning is unchanged: the agent extension re-sources deployments, connections, and toolboxes from the sibling services when setting provisioning environment variables and creating toolsets. + ## 0.1.41-preview (2026-06-19) - [[#8731]](https://github.com/Azure/azure-dev/pull/8731) Improve the post-deploy `Next:` guidance with a stacked layout that puts each command on its own line above its description, adds a blank line between suggestions, and highlights `azd` commands. The new layout applies across deploy, `azd ai agent show`, `init`, and `doctor`. Thanks @therealjohn for the contribution! diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index 51b6a1b2cc9..253d4cf16b7 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -5,7 +5,7 @@ displayName: Foundry agents (Preview) description: Ship agents with Microsoft Foundry from your terminal. (Preview) usage: azd ai agent [options] # NOTE: Make sure version.txt is in sync with this version. -version: 0.1.41-preview +version: 0.1.42-preview requiredAzdVersion: ">1.25.2" dependencies: - id: azure.ai.inspector diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 3aa02ed3fd4..8e18b21a14c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -2933,6 +2933,19 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa agentConfig.StartupCommand = startupCmd } + // Each Foundry resource is written as its own azure.yaml service entry, so + // the deployments, connections, and toolboxes move out of the agent config + // into sibling azure.ai.project/connection/toolbox services emitted below. + // The agent keeps its container, resources, tool connections, and startup + // command. The provisioning handlers re-source the moved data from the + // sibling services. + resourceDeployments := agentConfig.Deployments + resourceConnections := agentConfig.Connections + resourceToolboxes := agentConfig.Toolboxes + agentConfig.Deployments = nil + 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) @@ -2970,6 +2983,15 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa return fmt.Errorf("adding agent service to project: %w", err) } + // Emit the sibling Foundry resource services (project + deployments, + // connections, toolboxes) and wire the agent's uses: to them. + if err := emitResourceServices( + ctx, a.azdClient, a.serviceNameOverride, + resourceDeployments, resourceConnections, resourceToolboxes, + ); err != nil { + return err + } + fmt.Printf( "\nAdded your agent as a service entry named '%s' under the file azure.yaml.\n", a.serviceNameOverride, 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 f6189075992..4c6b64b76f6 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 @@ -832,6 +832,11 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, agentConfig.StartupCommand = startupCmd } + // Move the model deployments out of the agent config into a sibling + // azure.ai.project service, emitted after the agent service below. + resourceDeployments := agentConfig.Deployments + agentConfig.Deployments = nil + var agentConfigStruct *structpb.Struct var err error if agentConfigStruct, err = project.MarshalStruct(&agentConfig); err != nil { @@ -888,6 +893,15 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, return fmt.Errorf("adding agent service to project: %w", err) } + // Emit the sibling azure.ai.project service carrying the model deployments + // and wire the agent's uses: to it. + agentServiceName := strings.ReplaceAll(agentName, " ", "") + if err := emitResourceServices( + ctx, a.azdClient, agentServiceName, resourceDeployments, nil, nil, + ); err != nil { + return err + } + fmt.Printf("\nAdded your agent as a service entry named '%s' under the file azure.yaml.\n", agentName) return nil } 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 1d72a4d6b8f..209103bcd12 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -62,13 +62,22 @@ func configureExtensionHost(host *azdext.ExtensionHost) { } func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { + deployments, err := collectProjectDeployments(args.Project.Services) + if err != nil { + return err + } + connections, err := collectConnections(args.Project.Services) + if err != nil { + return err + } + for _, svc := range args.Project.Services { switch svc.Host { case AiAgentHost: if err := populateContainerSettings(ctx, azdClient, svc); err != nil { return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) } - if err := envUpdate(ctx, azdClient, args.Project, svc); err != nil { + if err := envUpdate(ctx, azdClient, args.Project, svc, deployments, connections); err != nil { return fmt.Errorf("failed to update environment for service %q: %w", svc.Name, err) } } @@ -82,18 +91,34 @@ func postprovisionHandler( azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs, ) error { - hasAgent := false - for _, svc := range args.Project.Services { - if svc.Host != AiAgentHost { - continue + // 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) } - hasAgent = true - if err := provisionToolboxes(ctx, azdClient, svc); err != nil { - return fmt.Errorf( - "failed to provision toolboxes for service %q: %w", - svc.Name, err, - ) + if err := provisionToolboxes(ctx, azdClient, toolboxes, connections, toolConnections); err != nil { + return fmt.Errorf("failed to provision toolboxes: %w", err) + } + } + + hasAgent := false + for _, svc := range args.Project.Services { + if svc.Host == AiAgentHost { + hasAgent = true + break } } @@ -156,6 +181,15 @@ func currentEnvName(ctx context.Context, azdClient *azdext.AzdClient) (string, e } func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { + deployments, err := collectProjectDeployments(args.Project.Services) + if err != nil { + return err + } + connections, err := collectConnections(args.Project.Services) + if err != nil { + return err + } + hasHostedAgentService := false for _, svc := range args.Project.Services { if svc.Host != AiAgentHost { @@ -165,7 +199,7 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az if err := populateContainerSettings(ctx, azdClient, svc); err != nil { return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) } - if err := envUpdate(ctx, azdClient, args.Project, svc); err != nil { + if err := envUpdate(ctx, azdClient, args.Project, svc, deployments, connections); err != nil { return fmt.Errorf("failed to update environment for service %q: %w", svc.Name, err) } @@ -376,7 +410,14 @@ func cleanupAgentSessionState(ctx context.Context, azdClient *azdext.AzdClient, return !failed } -func envUpdate(ctx context.Context, azdClient *azdext.AzdClient, azdProject *azdext.ProjectConfig, svc *azdext.ServiceConfig) error { +func envUpdate( + ctx context.Context, + azdClient *azdext.AzdClient, + azdProject *azdext.ProjectConfig, + svc *azdext.ServiceConfig, + deployments []project.Deployment, + connections []project.Connection, +) error { var foundryAgentConfig *project.ServiceTargetAgentConfig @@ -393,28 +434,31 @@ func envUpdate(ctx context.Context, azdClient *azdext.AzdClient, azdProject *azd return err } - if len(foundryAgentConfig.Deployments) > 0 { - if err := deploymentEnvUpdate(ctx, foundryAgentConfig.Deployments, azdClient, currentEnvResponse.Environment.Name); err != nil { + // Deployments and connections are sourced from the sibling + // azure.ai.project and azure.ai.connection services. Resources and tool + // connections stay on the agent service. + if len(deployments) > 0 { + if err := deploymentEnvUpdate(ctx, deployments, azdClient, currentEnvResponse.Environment.Name); err != nil { return err } } - if len(foundryAgentConfig.Resources) > 0 { + if foundryAgentConfig != nil && len(foundryAgentConfig.Resources) > 0 { if err := resourcesEnvUpdate(ctx, foundryAgentConfig.Resources, azdClient, currentEnvResponse.Environment.Name); err != nil { return err } } - if len(foundryAgentConfig.Connections) > 0 { + if len(connections) > 0 { if err := connectionsEnvUpdate( - ctx, foundryAgentConfig.Connections, + ctx, connections, azdClient, currentEnvResponse.Environment.Name, ); err != nil { return err } } - if len(foundryAgentConfig.ToolConnections) > 0 { + if foundryAgentConfig != nil && len(foundryAgentConfig.ToolConnections) > 0 { if err := toolConnectionsEnvUpdate( ctx, foundryAgentConfig.ToolConnections, azdClient, currentEnvResponse.Environment.Name, @@ -674,20 +718,28 @@ func populateContainerSettings(ctx context.Context, azdClient *azdext.AzdClient, // provisionToolboxes creates or updates Foundry Toolsets for each toolbox // in the service config. Called during post-provision after the project // endpoint has been created by Bicep. +// 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, - svc *azdext.ServiceConfig, + toolboxes []project.Toolbox, + connections []project.Connection, + toolConnections []project.ToolConnection, ) error { - var config *project.ServiceTargetAgentConfig - if err := project.UnmarshalStruct(svc.Config, &config); err != nil { - return fmt.Errorf("failed to parse service config: %w", err) - } - - if config == nil || len(config.Toolboxes) == 0 { + 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{}, ) @@ -751,10 +803,7 @@ func provisionToolboxes( return fmt.Errorf("loading connection IDs: %w", err) } - // Build connection lookup for enriching tool entries with server_url/server_label - connByName := toolboxConnectionsByName(config) - - for _, toolbox := range config.Toolboxes { + for _, toolbox := range toolboxes { fmt.Fprintf( os.Stderr, "Provisioning toolbox: %s\n", toolbox.Name, ) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go new file mode 100644 index 00000000000..4f3ddb5eb17 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -0,0 +1,274 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "sort" + "strings" + + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/protobuf/types/known/structpb" +) + +// Foundry resource service hosts. Each Foundry resource is written to azure.yaml +// as its own service entry keyed by the resource name, carrying a singular +// host: azure.ai.. The owning extension registers a service-target +// provider for the host so `azd up`/`provision`/`deploy` can walk the service. +const ( + // AiProjectHost owns the Foundry project and its model deployments. + AiProjectHost = "azure.ai.project" + // AiConnectionHost owns a single Foundry project connection. + AiConnectionHost = "azure.ai.connection" + // AiToolboxHost owns a single Foundry toolbox (toolset). + AiToolboxHost = "azure.ai.toolbox" + + // aiProjectServiceName is the stable azure.yaml service key used for the + // single azure.ai.project service. A stable name keeps repeated inits + // idempotent (AddService overwrites by name) so there is one project + // service per project, matching the unified Foundry config design. + aiProjectServiceName = "ai-project" +) + +// emitResourceServices writes the Foundry resource sibling services that the +// agent depends on (one azure.ai.project carrying the model deployments, one +// azure.ai.connection per connection, one azure.ai.toolbox per toolbox) and +// wires the agent service's uses: list to them for ordering. Each resource is +// its own azure.yaml service entry so a different extension can own each host. +func emitResourceServices( + ctx context.Context, + azdClient *azdext.AzdClient, + agentServiceName string, + deployments []project.Deployment, + connections []project.Connection, + toolboxes []project.Toolbox, +) error { + var agentUses []string + + // One project service owns the model deployments. Deployments stay an + // array on it (there is a single Foundry project and deployments belong + // to it). + projectServiceName := "" + if len(deployments) > 0 { + projectCfg, err := project.MarshalStruct(&project.ServiceTargetAgentConfig{Deployments: deployments}) + if err != nil { + return fmt.Errorf("marshaling project service config: %w", err) + } + projectServiceName = aiProjectServiceName + if err := addResourceService(ctx, azdClient, projectServiceName, AiProjectHost, projectCfg, nil); err != nil { + return err + } + agentUses = append(agentUses, projectServiceName) + } + + // Connection and toolbox services depend on the project service when one + // exists, so the project is provisioned first. + var siblingUses []string + if projectServiceName != "" { + siblingUses = []string{projectServiceName} + } + + for i := range connections { + conn := connections[i] + connName := sanitizeServiceName(conn.Name) + if connName == "" { + continue + } + connCfg, err := project.MarshalStruct(&conn) + if err != nil { + return fmt.Errorf("marshaling connection service %q config: %w", connName, err) + } + if err := addResourceService(ctx, azdClient, connName, AiConnectionHost, connCfg, siblingUses); err != nil { + return err + } + agentUses = append(agentUses, connName) + } + + for i := range toolboxes { + toolbox := toolboxes[i] + toolboxName := sanitizeServiceName(toolbox.Name) + if toolboxName == "" { + continue + } + toolboxCfg, err := project.MarshalStruct(&toolbox) + if err != nil { + return fmt.Errorf("marshaling toolbox service %q config: %w", toolboxName, err) + } + if err := addResourceService(ctx, azdClient, toolboxName, AiToolboxHost, toolboxCfg, siblingUses); err != nil { + return err + } + agentUses = append(agentUses, toolboxName) + } + + // Wire the agent service to its resource siblings so azd walks them first. + if len(agentUses) > 0 && agentServiceName != "" { + if err := setServiceUses(ctx, azdClient, agentServiceName, agentUses); err != nil { + return err + } + } + + return nil +} + +// 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. +func addResourceService( + ctx context.Context, + azdClient *azdext.AzdClient, + name string, + host string, + cfg *structpb.Struct, + uses []string, +) error { + svc := &azdext.ServiceConfig{ + Name: name, + Host: host, + Config: cfg, + } + + if _, err := azdClient.Project().AddService(ctx, &azdext.AddServiceRequest{Service: svc}); err != nil { + return fmt.Errorf("adding %s service %q: %w", host, name, err) + } + + if len(uses) > 0 { + if err := setServiceUses(ctx, azdClient, name, uses); err != nil { + return err + } + } + + return nil +} + +// setServiceUses sets the uses: list on an existing service. uses is a real +// core ServiceConfig field, so it is written via SetServiceConfigValue (a raw +// map path) rather than AddService's inlined config map, which cannot carry it. +func setServiceUses(ctx context.Context, azdClient *azdext.AzdClient, serviceName string, uses []string) error { + usesItems := make([]any, len(uses)) + for i, u := range uses { + usesItems[i] = u + } + + usesValue, err := structpb.NewValue(usesItems) + if err != nil { + return fmt.Errorf("encoding uses for service %q: %w", serviceName, err) + } + + if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: "uses", + Value: usesValue, + }); err != nil { + return fmt.Errorf("setting uses for service %q: %w", serviceName, err) + } + + 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. +func sanitizeServiceName(name string) string { + return strings.ReplaceAll(strings.TrimSpace(name), " ", "") +} + +// collectProjectDeployments gathers the model deployments declared across all +// azure.ai.project services so provisioning handlers can source them from the +// sibling project service instead of the agent service config. Services are +// visited in sorted name order so serialized env-var output stays stable. +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 { + continue + } + var cfg *project.ServiceTargetAgentConfig + if err := project.UnmarshalStruct(svc.Config, &cfg); err != nil { + return nil, fmt.Errorf("parsing project service %q config: %w", svc.Name, err) + } + if cfg != nil { + out = append(out, cfg.Deployments...) + } + } + return out, nil +} + +// collectConnections gathers the connections declared across all +// azure.ai.connection services. +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 { + continue + } + var conn *project.Connection + if err := project.UnmarshalStruct(svc.Config, &conn); err != nil { + return nil, fmt.Errorf("parsing connection service %q config: %w", svc.Name, err) + } + if conn != nil { + out = append(out, *conn) + } + } + return out, nil +} + +// collectToolboxes gathers the toolboxes declared across all azure.ai.toolbox +// services. +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 { + continue + } + var toolbox *project.Toolbox + if err := project.UnmarshalStruct(svc.Config, &toolbox); err != nil { + return nil, fmt.Errorf("parsing toolbox service %q config: %w", svc.Name, err) + } + if toolbox != nil { + out = append(out, *toolbox) + } + } + return out, nil +} + +// collectAgentToolConnections gathers the tool connections declared on agent +// services. Tool connections stay on the agent service (they are agent tool +// configuration), so toolbox enrichment still needs them alongside the +// connections sourced from azure.ai.connection services. +func collectAgentToolConnections(services map[string]*azdext.ServiceConfig) ([]project.ToolConnection, error) { + var out []project.ToolConnection + for _, svc := range sortedServices(services) { + if svc.Host != AiAgentHost || svc.Config == nil { + continue + } + var cfg *project.ServiceTargetAgentConfig + if err := project.UnmarshalStruct(svc.Config, &cfg); err != nil { + return nil, fmt.Errorf("parsing agent service %q config: %w", svc.Name, err) + } + if cfg != nil { + out = append(out, cfg.ToolConnections...) + } + } + return out, nil +} + +// sortedServices returns the services ordered by their map key so callers that +// serialize collected resources produce deterministic output across runs. +func sortedServices(services map[string]*azdext.ServiceConfig) []*azdext.ServiceConfig { + keys := make([]string, 0, len(services)) + for k := range services { + keys = append(keys, k) + } + sort.Strings(keys) + + out := make([]*azdext.ServiceConfig, 0, len(services)) + for _, k := range keys { + out = append(out, services[k]) + } + return out +} 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 new file mode 100644 index 00000000000..4a95fbddd04 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mustMarshalConfig[T any](t *testing.T, in *T) *azdext.ServiceConfig { + t.Helper() + cfg, err := project.MarshalStruct(in) + require.NoError(t, err) + return &azdext.ServiceConfig{Config: cfg} +} + +func projectService(t *testing.T, name string, deployments ...project.Deployment) *azdext.ServiceConfig { + t.Helper() + svc := mustMarshalConfig(t, &project.ServiceTargetAgentConfig{Deployments: deployments}) + svc.Name = name + svc.Host = AiProjectHost + return svc +} + +func connectionService(t *testing.T, name string, conn project.Connection) *azdext.ServiceConfig { + t.Helper() + svc := mustMarshalConfig(t, &conn) + svc.Name = name + svc.Host = AiConnectionHost + return svc +} + +func toolboxService(t *testing.T, name string, toolbox project.Toolbox) *azdext.ServiceConfig { + t.Helper() + svc := mustMarshalConfig(t, &toolbox) + svc.Name = name + svc.Host = AiToolboxHost + return svc +} + +func agentService(t *testing.T, name string, toolConnections ...project.ToolConnection) *azdext.ServiceConfig { + t.Helper() + svc := mustMarshalConfig(t, &project.ServiceTargetAgentConfig{ToolConnections: toolConnections}) + svc.Name = name + svc.Host = AiAgentHost + return svc +} + +// TestSanitizeServiceName verifies resource names are normalized into valid +// azure.yaml service keys (spaces removed, surrounding whitespace trimmed). +func TestSanitizeServiceName(t *testing.T) { + t.Parallel() + + assert.Equal(t, "MyAgent", sanitizeServiceName(" My Agent ")) + assert.Equal(t, "gpt4o", sanitizeServiceName("gpt 4 o")) + assert.Equal(t, "", sanitizeServiceName(" ")) +} + +// TestCollectProjectDeployments verifies deployments are sourced only from +// azure.ai.project services and ignore sibling hosts. +func TestCollectProjectDeployments(t *testing.T) { + t.Parallel() + + dep := project.Deployment{Name: "gpt-4o", Model: project.DeploymentModel{Name: "gpt-4o"}} + services := map[string]*azdext.ServiceConfig{ + "ai-project": projectService(t, "ai-project", dep), + "agent": agentService(t, "agent"), + "conn": connectionService(t, "conn", project.Connection{Name: "conn"}), + } + + deployments, err := collectProjectDeployments(services) + require.NoError(t, err) + require.Len(t, deployments, 1) + assert.Equal(t, "gpt-4o", deployments[0].Name) +} + +// TestCollectConnections verifies connections are sourced from +// azure.ai.connection services in deterministic (sorted) order. +func TestCollectConnections(t *testing.T) { + t.Parallel() + + services := map[string]*azdext.ServiceConfig{ + "zeta": connectionService(t, "zeta", project.Connection{Name: "zeta", Category: "ApiKey"}), + "alpha": connectionService(t, "alpha", project.Connection{Name: "alpha", Category: "ApiKey"}), + "ai-project": projectService(t, "ai-project"), + "agent": agentService(t, "agent"), + } + + connections, err := collectConnections(services) + require.NoError(t, err) + require.Len(t, connections, 2) + // Sorted by service key (alpha before zeta) for stable env-var output. + assert.Equal(t, "alpha", connections[0].Name) + assert.Equal(t, "zeta", connections[1].Name) +} + +// TestCollectToolboxes verifies toolboxes are sourced from azure.ai.toolbox +// services only. +func TestCollectToolboxes(t *testing.T) { + t.Parallel() + + services := map[string]*azdext.ServiceConfig{ + "tb": toolboxService(t, "tb", project.Toolbox{Name: "tb", Tools: []map[string]any{{"type": "mcp"}}}), + "agent": agentService(t, "agent"), + } + + toolboxes, err := collectToolboxes(services) + require.NoError(t, err) + require.Len(t, toolboxes, 1) + assert.Equal(t, "tb", toolboxes[0].Name) + require.Len(t, toolboxes[0].Tools, 1) +} + +// TestCollectAgentToolConnections verifies tool connections stay on the agent +// service and are sourced from there for toolbox enrichment. +func TestCollectAgentToolConnections(t *testing.T) { + t.Parallel() + + tc := project.ToolConnection{Name: "mcp-conn", Category: "CustomKeys", Target: "https://example.com"} + services := map[string]*azdext.ServiceConfig{ + "agent": agentService(t, "agent", tc), + "ai-project": projectService(t, "ai-project"), + } + + toolConnections, err := collectAgentToolConnections(services) + require.NoError(t, err) + require.Len(t, toolConnections, 1) + assert.Equal(t, "mcp-conn", toolConnections[0].Name) +} + +// TestCollectHelpers_EmptyAndNilConfigs verifies the collectors tolerate +// services with nil config and unrelated hosts without error. +func TestCollectHelpers_EmptyAndNilConfigs(t *testing.T) { + t.Parallel() + + services := map[string]*azdext.ServiceConfig{ + "web": {Name: "web", Host: "containerapp"}, + "nilcfg": {Name: "nilcfg", Host: AiProjectHost}, + } + + deployments, err := collectProjectDeployments(services) + require.NoError(t, err) + assert.Empty(t, deployments) + + connections, err := collectConnections(services) + require.NoError(t, err) + assert.Empty(t, connections) + + toolboxes, err := collectToolboxes(services) + require.NoError(t, err) + assert.Empty(t, toolboxes) +} diff --git a/cli/azd/extensions/azure.ai.agents/version.txt b/cli/azd/extensions/azure.ai.agents/version.txt index de4db352fba..d76fe6c36e1 100644 --- a/cli/azd/extensions/azure.ai.agents/version.txt +++ b/cli/azd/extensions/azure.ai.agents/version.txt @@ -1 +1 @@ -0.1.41-preview +0.1.42-preview diff --git a/cli/azd/extensions/azure.ai.connections/CHANGELOG.md b/cli/azd/extensions/azure.ai.connections/CHANGELOG.md index 3c77b09ab96..bd008ae134e 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 + +- Register the `azure.ai.connection` service target. `azd ai agent init` can now write Foundry connections as their own `azure.ai.connection` service entries wired to the agent via `uses:`, and this extension registers the host so `azd up`/`azd deploy` succeed for those entries. Connections continue to be provisioned by Bicep during `azd provision`, so the deploy-time hook is intentionally a no-op. + ## 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..d05f45e921a 100644 --- a/cli/azd/extensions/azure.ai.connections/extension.yaml +++ b/cli/azd/extensions/azure.ai.connections/extension.yaml @@ -2,13 +2,18 @@ capabilities: - custom-commands - metadata + - service-target-provider 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: Registers Foundry connection service entries so azd up/deploy succeed 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/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/listen.go new file mode 100644 index 00000000000..9cf57dbb07f --- /dev/null +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/listen.go @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "azure.ai.connections/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// configureExtensionHost registers the azure.ai.connection service target on +// the supplied host. It is passed to azdext.NewListenCommand from the root +// command, which handles the surrounding setup (access token, AzdClient +// creation, and the host.Run lifecycle). +func configureExtensionHost(host *azdext.ExtensionHost) { + azdClient := host.Client() + + // IMPORTANT: the host name must match the provider name in extension.yaml. + host.WithServiceTarget(project.ConnectionHost, func() azdext.ServiceTargetProvider { + return project.NewConnectionServiceTargetProvider(azdClient) + }) +} 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..faf9f559aee 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/root.go @@ -27,6 +27,10 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) rootCmd.AddCommand(newMetadataCommand(rootCmd)) + // Register the azure.ai.connection service target so `azd up`/`azd deploy` + // succeed for connection service entries written by `azd ai agent init`. + rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) + // Register -p / --project-endpoint as a persistent flag inherited by // connection CRUD subcommands (list, show, create, update, delete). rootCmd.PersistentFlags().StringP("project-endpoint", "p", "", diff --git a/cli/azd/extensions/azure.ai.connections/internal/project/service_target.go b/cli/azd/extensions/azure.ai.connections/internal/project/service_target.go new file mode 100644 index 00000000000..7b71536ef6a --- /dev/null +++ b/cli/azd/extensions/azure.ai.connections/internal/project/service_target.go @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package project implements the azd service target for the azure.ai.connection host. +package project + +import ( + "context" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// ConnectionHost is the azd service host served by this extension. It must +// match the provider name declared in extension.yaml. +const ConnectionHost = "azure.ai.connection" + +var _ azdext.ServiceTargetProvider = (*ConnectionServiceTargetProvider)(nil) + +// ConnectionServiceTargetProvider is a no-op service target for the +// azure.ai.connection host. Foundry connections are created by Bicep during +// `azd provision` (orchestrated by the Foundry agents extension), so the +// deploy-time hooks here intentionally do nothing. Registering the host is +// what lets `azd up`/`azd deploy` succeed for connection service entries that +// an agent service references via `uses:`. +type ConnectionServiceTargetProvider struct { + azdClient *azdext.AzdClient + serviceConfig *azdext.ServiceConfig +} + +// NewConnectionServiceTargetProvider creates a no-op connection service target. +func NewConnectionServiceTargetProvider(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { + return &ConnectionServiceTargetProvider{azdClient: azdClient} +} + +// Initialize stores the service configuration; no other setup is required. +func (p *ConnectionServiceTargetProvider) Initialize( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, +) error { + p.serviceConfig = serviceConfig + return nil +} + +// Endpoints returns no endpoints; connections do not expose any. +func (p *ConnectionServiceTargetProvider) Endpoints( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + targetResource *azdext.TargetResource, +) ([]string, error) { + return nil, nil +} + +// GetTargetResource resolves the target resource. Connections have no +// standalone ARM resource, so it delegates to azd's default resolver and +// falls back to a minimal target so the deploy pipeline can proceed. +func (p *ConnectionServiceTargetProvider) 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 connection. +func (p *ConnectionServiceTargetProvider) 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; connections have no artifacts to publish. +func (p *ConnectionServiceTargetProvider) 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 connection is created at provision time by Bicep. +func (p *ConnectionServiceTargetProvider) 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.connections/version.txt b/cli/azd/extensions/azure.ai.connections/version.txt index 15b416cc5ea..18cc3624f44 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 diff --git a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md index 6c6875ab6dd..80a3b60d887 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 + +- Register the `azure.ai.project` service target. `azd ai agent init` can now write the Foundry project (and its model deployments) as its own `azure.ai.project` service entry wired to the agent via `uses:`, and this extension registers the host so `azd up`/`azd deploy` succeed for that entry. The project and its deployments continue to be provisioned by Bicep during `azd provision`, so the deploy-time hook is intentionally a no-op. + ## 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..d6fa1efb3ab 100644 --- a/cli/azd/extensions/azure.ai.projects/extension.yaml +++ b/cli/azd/extensions/azure.ai.projects/extension.yaml @@ -2,13 +2,18 @@ capabilities: - custom-commands - metadata + - service-target-provider 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: Registers Foundry project service entries so azd up/deploy succeed 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/listen.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/listen.go new file mode 100644 index 00000000000..4ae08ccc2b5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/listen.go @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "azure.ai.projects/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// configureExtensionHost registers the azure.ai.project service target on the +// supplied host. It is passed to azdext.NewListenCommand from the root command, +// which handles the surrounding setup (access token, AzdClient creation, and +// the host.Run lifecycle). +func configureExtensionHost(host *azdext.ExtensionHost) { + azdClient := host.Client() + + // IMPORTANT: the host name must match the provider name in extension.yaml. + host.WithServiceTarget(project.ProjectHost, func() azdext.ServiceTargetProvider { + return project.NewProjectServiceTargetProvider(azdClient) + }) +} 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..245d798e07f 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go @@ -30,5 +30,9 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newProjectUnsetCommand(extCtx)) rootCmd.AddCommand(newProjectShowCommand(extCtx)) + // Register the azure.ai.project service target so `azd up`/`azd deploy` + // succeed for project service entries written by `azd ai agent init`. + rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) + return rootCmd } diff --git a/cli/azd/extensions/azure.ai.projects/internal/project/service_target.go b/cli/azd/extensions/azure.ai.projects/internal/project/service_target.go new file mode 100644 index 00000000000..a9b93a9f07a --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/project/service_target.go @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package project implements the azd service target for the azure.ai.project host. +package project + +import ( + "context" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// ProjectHost is the azd service host served by this extension. It must match +// the provider name declared in extension.yaml. +const ProjectHost = "azure.ai.project" + +var _ azdext.ServiceTargetProvider = (*ProjectServiceTargetProvider)(nil) + +// ProjectServiceTargetProvider is a no-op service target for the +// azure.ai.project host. The Foundry project and its model deployments are +// created by Bicep during `azd provision` (orchestrated by the Foundry agents +// extension), so the deploy-time hooks here intentionally do nothing. +// Registering the host is what lets `azd up`/`azd deploy` succeed for project +// service entries that an agent service references via `uses:`. +type ProjectServiceTargetProvider struct { + azdClient *azdext.AzdClient + serviceConfig *azdext.ServiceConfig +} + +// NewProjectServiceTargetProvider creates a no-op project service target. +func NewProjectServiceTargetProvider(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { + return &ProjectServiceTargetProvider{azdClient: azdClient} +} + +// Initialize stores the service configuration; no other setup is required. +func (p *ProjectServiceTargetProvider) Initialize( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, +) error { + p.serviceConfig = serviceConfig + return nil +} + +// Endpoints returns no endpoints; the project service does not expose any. +func (p *ProjectServiceTargetProvider) 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 *ProjectServiceTargetProvider) 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 the project service. +func (p *ProjectServiceTargetProvider) 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 service has no artifacts to publish. +func (p *ProjectServiceTargetProvider) 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 deployments are created at provision time by Bicep. +func (p *ProjectServiceTargetProvider) 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/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.toolboxes/CHANGELOG.md b/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md index 232276e97ad..fbaa770c063 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 + +- Register the `azure.ai.toolbox` service target. `azd ai agent init` can now write Foundry toolboxes as their own `azure.ai.toolbox` service entries wired to the agent via `uses:`, and this extension registers the host so `azd up`/`azd deploy` succeed for those entries. Toolboxes continue to be created via the dataplane API during `azd provision`, so the deploy-time hook is intentionally a no-op. + ## 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..4f1ceae8f2c 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/extension.yaml +++ b/cli/azd/extensions/azure.ai.toolboxes/extension.yaml @@ -2,13 +2,18 @@ capabilities: - custom-commands - metadata + - service-target-provider 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: Registers Foundry toolbox service entries so azd up/deploy succeed 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/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/listen.go new file mode 100644 index 00000000000..481a59d592a --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/listen.go @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "azure.ai.toolboxes/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// configureExtensionHost registers the azure.ai.toolbox service target on the +// supplied host. It is passed to azdext.NewListenCommand from the root command, +// which handles the surrounding setup (access token, AzdClient creation, and +// the host.Run lifecycle). +func configureExtensionHost(host *azdext.ExtensionHost) { + azdClient := host.Client() + + // IMPORTANT: the host name must match the provider name in extension.yaml. + host.WithServiceTarget(project.ToolboxHost, func() azdext.ServiceTargetProvider { + return project.NewToolboxServiceTargetProvider(azdClient) + }) +} 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..7a0155f348e 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go @@ -54,5 +54,9 @@ to promote a version.`, rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) rootCmd.AddCommand(newMetadataCommand(rootCmd)) + // Register the azure.ai.toolbox service target so `azd up`/`azd deploy` + // succeed for toolbox service entries written by `azd ai agent init`. + rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) + return rootCmd } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/project/service_target.go b/cli/azd/extensions/azure.ai.toolboxes/internal/project/service_target.go new file mode 100644 index 00000000000..3c8c97a8ffa --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/project/service_target.go @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package project implements the azd service target for the azure.ai.toolbox host. +package project + +import ( + "context" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// ToolboxHost is the azd service host served by this extension. It must match +// the provider name declared in extension.yaml. +const ToolboxHost = "azure.ai.toolbox" + +var _ azdext.ServiceTargetProvider = (*ToolboxServiceTargetProvider)(nil) + +// ToolboxServiceTargetProvider is a no-op service target for the +// azure.ai.toolbox host. Foundry toolboxes are created via the dataplane API +// during `azd provision` (orchestrated by the Foundry agents extension's +// post-provision hook), so the deploy-time hooks here intentionally do +// nothing. Registering the host is what lets `azd up`/`azd deploy` succeed for +// toolbox service entries that an agent service references via `uses:`. +type ToolboxServiceTargetProvider struct { + azdClient *azdext.AzdClient + serviceConfig *azdext.ServiceConfig +} + +// NewToolboxServiceTargetProvider creates a no-op toolbox service target. +func NewToolboxServiceTargetProvider(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { + return &ToolboxServiceTargetProvider{azdClient: azdClient} +} + +// Initialize stores the service configuration; no other setup is required. +func (p *ToolboxServiceTargetProvider) Initialize( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, +) error { + p.serviceConfig = serviceConfig + return nil +} + +// Endpoints returns no endpoints; toolboxes do not expose any. +func (p *ToolboxServiceTargetProvider) Endpoints( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + targetResource *azdext.TargetResource, +) ([]string, error) { + return nil, nil +} + +// GetTargetResource resolves the target resource. Toolboxes have no standalone +// ARM resource, so it delegates to azd's default resolver and falls back to a +// minimal target so the deploy pipeline can proceed. +func (p *ToolboxServiceTargetProvider) 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 toolbox. +func (p *ToolboxServiceTargetProvider) 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; toolboxes have no artifacts to publish. +func (p *ToolboxServiceTargetProvider) 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 toolbox is created at provision time via the dataplane API. +func (p *ToolboxServiceTargetProvider) 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.toolboxes/version.txt b/cli/azd/extensions/azure.ai.toolboxes/version.txt index 9ff8406fee4..15b416cc5ea 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 From e8737f564bb351fe8255b914dfbb2071f0bd0cfd Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 22 Jun 2026 20:39:55 +0800 Subject: [PATCH 07/50] feat: remove unregistered microsoft.foundry service target --- .../internal/project/foundry_config.go | 371 --------------- .../internal/project/foundry_config_test.go | 300 ------------ .../project/foundry_project_resolve.go | 232 --------- .../project/foundry_project_resolve_test.go | 114 ----- .../internal/project/service_target_agent.go | 2 +- .../project/service_target_foundry.go | 449 ------------------ 6 files changed, 1 insertion(+), 1467 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/foundry_config.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/foundry_config_test.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve_test.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config.go deleted file mode 100644 index 079f66ae465..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config.go +++ /dev/null @@ -1,371 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package project - -import ( - "fmt" - "strings" - - "azureaiagent/internal/exterrors" - "azureaiagent/internal/pkg/agents/agent_yaml" -) - -// FoundryHost is the azure.yaml service host kind for a unified Foundry project. -// A single service with this host owns the Foundry project and all of its -// data-plane state (deployments, connections, toolboxes, skills, routines, and -// agents) declared as top-level service properties (design spec #8590 §2.1). -const FoundryHost = "microsoft.foundry" - -// Agent kind discriminators for a FoundryAgent. -const ( - foundryAgentKindHosted = "hosted" - foundryAgentKindPrompt = "prompt" -) - -// FoundryProjectConfig is the typed view of a `host: microsoft.foundry` service -// entry. The keys arrive on ServiceConfig.AdditionalProperties (the inline map -// captured by yaml:",inline" in azd core) rather than under `config:`. -// -// Only `endpoint` and `agents` are typed here; the remaining project-scoped -// arrays are retained as raw maps because this foundation does not yet reconcile -// them (deferred to the data-plane reconcile work). They are kept on the struct -// so binding does not silently drop them. -type FoundryProjectConfig struct { - Endpoint string `json:"endpoint,omitempty"` - Deployments []map[string]any `json:"deployments,omitempty"` - Connections []map[string]any `json:"connections,omitempty"` - Toolboxes []map[string]any `json:"toolboxes,omitempty"` - Skills []map[string]any `json:"skills,omitempty"` - Routines []map[string]any `json:"routines,omitempty"` - Agents []FoundryAgent `json:"agents,omitempty"` -} - -// FoundryAgent is the union of a hosted agent and a prompt agent, matching -// Agent.json. A hosted agent carries exactly one deploy mode (`docker`, -// `runtime`, or a prebuilt `image`); a prompt agent carries `instructions`. -type FoundryAgent struct { - // Ref holds a `$ref` file include. Resolving includes is deferred to the - // $ref resolver work (#8627); this foundation rejects unresolved refs. - Ref string `json:"$ref,omitempty"` - - Name string `json:"name,omitempty"` - Kind string `json:"kind,omitempty"` - Description string `json:"description,omitempty"` - Env map[string]string `json:"env,omitempty"` - Toolboxes []string `json:"toolboxes,omitempty"` - Tools []map[string]any `json:"tools,omitempty"` - Skill string `json:"skill,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - - // Hosted-agent fields. - Protocols []AgentProtocol `json:"protocols,omitempty"` - Project string `json:"project,omitempty"` - Image string `json:"image,omitempty"` - Docker *AgentDocker `json:"docker,omitempty"` - Runtime *AgentRuntime `json:"runtime,omitempty"` - StartupCommand string `json:"startupCommand,omitempty"` - Container *AgentContainer `json:"container,omitempty"` - - // Prompt-agent fields. - Instructions string `json:"instructions,omitempty"` -} - -// AgentProtocol is a single protocol/version pair a hosted agent implements. -type AgentProtocol struct { - Protocol string `json:"protocol"` - Version string `json:"version"` -} - -// AgentDocker holds container build options for a hosted agent (container mode). -type AgentDocker struct { - Path string `json:"path,omitempty"` - RemoteBuild bool `json:"remoteBuild,omitempty"` -} - -// AgentRuntime holds the code-deploy runtime stack for a hosted agent. -type AgentRuntime struct { - Stack string `json:"stack,omitempty"` - Version string `json:"version,omitempty"` - RemoteBuild bool `json:"remoteBuild,omitempty"` -} - -// AgentContainer holds container runtime settings (CPU/memory) for a hosted agent. -type AgentContainer struct { - Resources *ResourceSettings `json:"resources,omitempty"` -} - -// deployMode identifies how a hosted agent is built and deployed. -type deployMode int - -const ( - deployModeNone deployMode = iota - deployModeImage - deployModeRuntime - deployModeDocker -) - -// deployMode reports the single deploy mode declared on a hosted agent. A hosted -// agent must declare exactly one of `image`, `runtime`, or `docker`; validation -// (see validateHostedAgent) rejects zero or more than one. -func (a FoundryAgent) deployMode() deployMode { - switch { - case a.Docker != nil: - return deployModeDocker - case a.Runtime != nil: - return deployModeRuntime - case a.Image != "": - return deployModeImage - default: - return deployModeNone - } -} - -// modeCount returns how many deploy modes are declared, used to enforce mutual -// exclusivity. -func (a FoundryAgent) modeCount() int { - count := 0 - if a.Docker != nil { - count++ - } - if a.Runtime != nil { - count++ - } - if a.Image != "" { - count++ - } - return count -} - -// Validate checks the Foundry project config for the subset this foundation -// supports: a single hosted agent with exactly one deploy mode. Multi-agent -// fan-out, prompt agents, and data-plane reconcile are intentionally out of -// scope and rejected with actionable errors. -func (c *FoundryProjectConfig) Validate() (FoundryAgent, error) { - if len(c.Agents) == 0 { - return FoundryAgent{}, exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - "no agents defined on the microsoft.foundry service", - "add an agent under the service 'agents:' array in azure.yaml", - ) - } - - if len(c.Agents) > 1 { - return FoundryAgent{}, exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("the microsoft.foundry service declares %d agents; "+ - "multiple agents per service are not yet supported", len(c.Agents)), - "declare a single agent in 'agents:' for now; multi-agent fan-out is coming in a later release", - ) - } - - agent := c.Agents[0] - if agent.Ref != "" { - return FoundryAgent{}, exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - "agents declared via '$ref' are not yet supported", - "inline the agent definition under 'agents:' in azure.yaml", - ) - } - - if err := validateAgent(agent); err != nil { - return FoundryAgent{}, err - } - - return agent, nil -} - -// validateAgent validates a single agent's kind and deploy mode. -func validateAgent(agent FoundryAgent) error { - if agent.Name == "" { - return exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - "agent is missing a 'name'", - "set a 'name' on the agent in azure.yaml", - ) - } - - switch agent.Kind { - case foundryAgentKindHosted: - return validateHostedAgent(agent) - case foundryAgentKindPrompt: - return exterrors.Validation( - exterrors.CodeUnsupportedAgentKind, - "prompt agents are not yet supported by the microsoft.foundry service target", - "use a hosted agent (kind: hosted) for now", - ) - case "": - return exterrors.Validation( - exterrors.CodeMissingAgentKind, - fmt.Sprintf("agent %q is missing a 'kind'", agent.Name), - "set 'kind: hosted' on the agent in azure.yaml", - ) - default: - return exterrors.Validation( - exterrors.CodeUnsupportedAgentKind, - fmt.Sprintf("agent %q has unsupported kind %q", agent.Name, agent.Kind), - "use a supported kind: 'hosted'", - ) - } -} - -// validateHostedAgent enforces exactly one deploy mode and the project -// requirement for build-based modes. -func validateHostedAgent(agent FoundryAgent) error { - switch n := agent.modeCount(); { - case n == 0: - return exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("hosted agent %q has no deploy mode", agent.Name), - "set exactly one of 'image', 'runtime', or 'docker' on the agent", - ) - case n > 1: - return exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("hosted agent %q declares more than one deploy mode", agent.Name), - "set exactly one of 'image', 'runtime', or 'docker' on the agent", - ) - } - - switch agent.deployMode() { - case deployModeRuntime: - if agent.Project == "" { - return exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("hosted agent %q sets 'runtime' but is missing 'project'", agent.Name), - "set 'project' to the agent source directory (relative to azure.yaml)", - ) - } - switch agent.Runtime.Stack { - case "python", "dotnet": - // supported by the code-deploy packaging + runtime command path - case "": - return exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("hosted agent %q sets 'runtime' but is missing 'stack'", agent.Name), - "set 'runtime.stack' to 'python' or 'dotnet'", - ) - default: - return exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("hosted agent %q uses runtime stack %q, which is not supported yet", - agent.Name, agent.Runtime.Stack), - "use a 'python' or 'dotnet' runtime stack, or a prebuilt 'image', for now", - ) - } - if agent.StartupCommand == "" { - return exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("hosted agent %q sets 'runtime' but is missing 'startupCommand'", agent.Name), - "set 'startupCommand' (e.g., 'python main.py') so the entry point can be resolved", - ) - } - case deployModeDocker: - return exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("hosted agent %q uses 'docker' build, which the microsoft.foundry "+ - "service target does not support yet", agent.Name), - "use a prebuilt 'image' or a code-deploy 'runtime' for now; "+ - "container builds land with per-agent build support", - ) - } - - return nil -} - -// toContainerAgent converts a validated hosted FoundryAgent into the -// agent_yaml.ContainerAgent shape the existing deploy machinery consumes, so the -// CreateAgentVersion request can be built with agent_yaml.CreateAgentAPIRequestFromDefinition. -func (a FoundryAgent) toContainerAgent() (agent_yaml.ContainerAgent, error) { - ca := agent_yaml.ContainerAgent{ - AgentDefinition: agent_yaml.AgentDefinition{ - Kind: agent_yaml.AgentKindHosted, - Name: a.Name, - }, - Image: a.Image, - } - - if a.Description != "" { - desc := a.Description - ca.Description = &desc - } - if len(a.Metadata) > 0 { - meta := a.Metadata - ca.Metadata = &meta - } - - for _, p := range a.Protocols { - ca.Protocols = append(ca.Protocols, agent_yaml.ProtocolVersionRecord{ - Protocol: p.Protocol, - Version: p.Version, - }) - } - - if a.deployMode() == deployModeRuntime { - entryPoint, err := a.codeEntryPoint() - if err != nil { - return agent_yaml.ContainerAgent{}, err - } - ca.CodeConfiguration = &agent_yaml.CodeConfiguration{ - Runtime: runtimeString(a.Runtime), - EntryPoint: entryPoint, - } - } - - return ca, nil -} - -// runtimeString maps the typed runtime block to the runtime identifier the -// Foundry API expects, e.g. {stack: python, version: "3.13"} -> "python_3_13". -func runtimeString(rt *AgentRuntime) string { - if rt == nil { - return "" - } - if rt.Version == "" { - return rt.Stack - } - return fmt.Sprintf("%s_%s", rt.Stack, strings.ReplaceAll(rt.Version, ".", "_")) -} - -// codeEntryPoint derives the code-deploy entry point from startupCommand by -// stripping a leading runtime command prefix (e.g. "python main.py" -> "main.py"). -// -// The Foundry agent schema models code-deploy entry via startupCommand rather -// than an explicit entryPoint field; this derivation is the documented seam if -// the schema later adds an explicit field. -func (a FoundryAgent) codeEntryPoint() (string, error) { - fields := strings.Fields(a.StartupCommand) - if len(fields) == 0 { - return "", exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("hosted agent %q has an empty 'startupCommand'", a.Name), - "set 'startupCommand' (e.g., 'python main.py')", - ) - } - - prefix := agent_yaml.RuntimeCmdPrefix(runtimeString(a.Runtime)) - if len(fields) > 1 && fields[0] == prefix { - return strings.Join(fields[1:], " "), nil - } - - // No recognizable prefix: treat the whole command as the entry point. - return strings.Join(fields, " "), nil -} - -// resolvedEnv expands the agent's env values, resolving azd ${VAR} references via -// the supplied environment while preserving Foundry ${{...}} expressions verbatim -// (design spec §2.5, shared ExpandEnv helper). -func (a FoundryAgent) resolvedEnv(azdEnv map[string]string) map[string]string { - if len(a.Env) == 0 { - return nil - } - resolved := make(map[string]string, len(a.Env)) - for k, v := range a.Env { - expanded, err := ExpandEnv(v, func(name string) string { return azdEnv[name] }) - if err != nil { - expanded = v - } - resolved[k] = expanded - } - return resolved -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config_test.go deleted file mode 100644 index 45e9f6cd18a..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_config_test.go +++ /dev/null @@ -1,300 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package project - -import ( - "testing" - - "azureaiagent/internal/pkg/agents/agent_yaml" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/types/known/structpb" -) - -func TestFoundryProjectConfig_Validate(t *testing.T) { - hostedImage := FoundryAgent{Name: "a", Kind: "hosted", Image: "reg.azurecr.io/a:1"} - hostedRuntime := FoundryAgent{ - Name: "a", - Kind: "hosted", - Project: "src/a", - Runtime: &AgentRuntime{Stack: "python", Version: "3.13"}, - StartupCommand: "python main.py", - } - - tests := []struct { - name string - config FoundryProjectConfig - wantErr bool - errSubstr string - }{ - {name: "valid image", config: FoundryProjectConfig{Agents: []FoundryAgent{hostedImage}}}, - {name: "valid runtime", config: FoundryProjectConfig{Agents: []FoundryAgent{hostedRuntime}}}, - {name: "no agents", config: FoundryProjectConfig{}, wantErr: true, errSubstr: "no agents"}, - { - name: "multiple agents", - config: FoundryProjectConfig{Agents: []FoundryAgent{hostedImage, hostedRuntime}}, - wantErr: true, - errSubstr: "multiple agents", - }, - { - name: "ref agent", - config: FoundryProjectConfig{Agents: []FoundryAgent{{Ref: "./a.yaml"}}}, - wantErr: true, - errSubstr: "$ref", - }, - { - name: "missing name", - config: FoundryProjectConfig{Agents: []FoundryAgent{{Kind: "hosted", Image: "x"}}}, - wantErr: true, - errSubstr: "name", - }, - { - name: "missing kind", - config: FoundryProjectConfig{Agents: []FoundryAgent{{Name: "a", Image: "x"}}}, - wantErr: true, - errSubstr: "kind", - }, - { - name: "prompt unsupported", - config: FoundryProjectConfig{Agents: []FoundryAgent{{Name: "a", Kind: "prompt", Instructions: "hi"}}}, - wantErr: true, - errSubstr: "prompt", - }, - { - name: "unknown kind", - config: FoundryProjectConfig{Agents: []FoundryAgent{{Name: "a", Kind: "wat"}}}, - wantErr: true, - errSubstr: "unsupported kind", - }, - { - name: "hosted no deploy mode", - config: FoundryProjectConfig{Agents: []FoundryAgent{{Name: "a", Kind: "hosted"}}}, - wantErr: true, - errSubstr: "no deploy mode", - }, - { - name: "hosted multiple deploy modes", - config: FoundryProjectConfig{Agents: []FoundryAgent{{ - Name: "a", Kind: "hosted", Image: "x", Runtime: &AgentRuntime{Stack: "python"}, - }}}, - wantErr: true, - errSubstr: "more than one deploy mode", - }, - { - name: "runtime missing project", - config: FoundryProjectConfig{Agents: []FoundryAgent{{ - Name: "a", Kind: "hosted", Runtime: &AgentRuntime{Stack: "python", Version: "3.13"}, StartupCommand: "python main.py", - }}}, - wantErr: true, - errSubstr: "project", - }, - { - name: "runtime missing startupCommand", - config: FoundryProjectConfig{Agents: []FoundryAgent{{ - Name: "a", Kind: "hosted", Project: "src/a", Runtime: &AgentRuntime{Stack: "python", Version: "3.13"}, - }}}, - wantErr: true, - errSubstr: "startupCommand", - }, - { - name: "runtime unsupported stack", - config: FoundryProjectConfig{Agents: []FoundryAgent{{ - Name: "a", Kind: "hosted", Project: "src/a", - Runtime: &AgentRuntime{Stack: "node", Version: "20"}, StartupCommand: "node index.js", - }}}, - wantErr: true, - errSubstr: "not supported", - }, - { - name: "docker unsupported", - config: FoundryProjectConfig{Agents: []FoundryAgent{{ - Name: "a", Kind: "hosted", Project: "src/a", Docker: &AgentDocker{Path: "Dockerfile"}, - }}}, - wantErr: true, - errSubstr: "does not support yet", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - agent, err := tt.config.Validate() - if tt.wantErr { - require.Error(t, err) - if tt.errSubstr != "" { - assert.Contains(t, err.Error(), tt.errSubstr) - } - return - } - require.NoError(t, err) - assert.Equal(t, "a", agent.Name) - }) - } -} - -func TestFoundryAgent_toContainerAgent_Image(t *testing.T) { - desc := "an agent" - agent := FoundryAgent{ - Name: "support", - Kind: "hosted", - Description: desc, - Image: "reg.azurecr.io/support:1", - Protocols: []AgentProtocol{{Protocol: "responses", Version: "1.0.0"}}, - Metadata: map[string]any{"team": "cx"}, - } - - ca, err := agent.toContainerAgent() - require.NoError(t, err) - - assert.Equal(t, agent_yaml.AgentKindHosted, ca.Kind) - assert.Equal(t, "support", ca.Name) - assert.Equal(t, "reg.azurecr.io/support:1", ca.Image) - require.NotNil(t, ca.Description) - assert.Equal(t, desc, *ca.Description) - assert.Nil(t, ca.CodeConfiguration) - require.Len(t, ca.Protocols, 1) - assert.Equal(t, "responses", ca.Protocols[0].Protocol) - require.NotNil(t, ca.Metadata) - assert.Equal(t, "cx", (*ca.Metadata)["team"]) -} - -func TestFoundryAgent_toContainerAgent_Runtime(t *testing.T) { - agent := FoundryAgent{ - Name: "code", - Kind: "hosted", - Project: "src/code", - Runtime: &AgentRuntime{Stack: "python", Version: "3.13"}, - StartupCommand: "python main.py", - } - - ca, err := agent.toContainerAgent() - require.NoError(t, err) - - require.NotNil(t, ca.CodeConfiguration) - assert.Equal(t, "python_3_13", ca.CodeConfiguration.Runtime) - assert.Equal(t, "main.py", ca.CodeConfiguration.EntryPoint) - assert.Empty(t, ca.Image) -} - -func TestRuntimeString(t *testing.T) { - assert.Equal(t, "python_3_13", runtimeString(&AgentRuntime{Stack: "python", Version: "3.13"})) - assert.Equal(t, "dotnet_8", runtimeString(&AgentRuntime{Stack: "dotnet", Version: "8"})) - assert.Equal(t, "python", runtimeString(&AgentRuntime{Stack: "python"})) - assert.Equal(t, "", runtimeString(nil)) -} - -func TestFoundryAgent_codeEntryPoint(t *testing.T) { - tests := []struct { - name string - agent FoundryAgent - want string - wantErr bool - }{ - { - name: "strips python prefix", - agent: FoundryAgent{ - Runtime: &AgentRuntime{Stack: "python", Version: "3.13"}, StartupCommand: "python main.py", - }, - want: "main.py", - }, - { - name: "strips dotnet prefix", - agent: FoundryAgent{ - Runtime: &AgentRuntime{Stack: "dotnet", Version: "8"}, StartupCommand: "dotnet MyAgent.dll", - }, - want: "MyAgent.dll", - }, - { - name: "no prefix keeps command", - agent: FoundryAgent{ - Runtime: &AgentRuntime{Stack: "python", Version: "3.13"}, StartupCommand: "main.py", - }, - want: "main.py", - }, - { - name: "empty command errors", - agent: FoundryAgent{Runtime: &AgentRuntime{Stack: "python", Version: "3.13"}, StartupCommand: " "}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := tt.agent.codeEntryPoint() - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestFoundryAgent_resolvedEnv(t *testing.T) { - agent := FoundryAgent{ - Env: map[string]string{ - "PLAIN": "value", - "FROM_AZD": "${MY_VAR}", - "FOUNDRY": "${{connections.x.key}}", - "MIXED": "${MY_VAR}-${{event.body}}", - "UNDEFINED": "${MISSING}", - }, - } - - resolved := agent.resolvedEnv(map[string]string{"MY_VAR": "hello"}) - - assert.Equal(t, "value", resolved["PLAIN"]) - assert.Equal(t, "hello", resolved["FROM_AZD"]) - assert.Equal(t, "${{connections.x.key}}", resolved["FOUNDRY"]) - assert.Equal(t, "hello-${{event.body}}", resolved["MIXED"]) - assert.Equal(t, "", resolved["UNDEFINED"]) -} - -func TestFoundryAgent_resolvedEnv_Empty(t *testing.T) { - assert.Nil(t, FoundryAgent{}.resolvedEnv(nil)) -} - -// TestFoundryProjectConfig_BindFromAdditionalProperties verifies the config binds -// from a structpb.Struct the way core delivers AdditionalProperties over gRPC. -func TestFoundryProjectConfig_BindFromAdditionalProperties(t *testing.T) { - raw := map[string]any{ - "endpoint": "https://acct.services.ai.azure.com/api/projects/p", - "deployments": []any{ - map[string]any{"name": "gpt-4.1-mini"}, - }, - "agents": []any{ - map[string]any{ - "name": "basic-agent", - "kind": "hosted", - "description": "A basic agent.", - "project": "src/basic-agent", - "startupCommand": "python main.py", - "runtime": map[string]any{"stack": "python", "version": "3.13"}, - "protocols": []any{ - map[string]any{"protocol": "responses", "version": "1.0.0"}, - }, - "env": map[string]any{"FOUNDRY_MODEL_DEPLOYMENT_NAME": "gpt-4.1-mini"}, - }, - }, - } - - s, err := structpb.NewStruct(raw) - require.NoError(t, err) - - var config *FoundryProjectConfig - require.NoError(t, UnmarshalStruct(s, &config)) - require.NotNil(t, config) - - assert.Equal(t, "https://acct.services.ai.azure.com/api/projects/p", config.Endpoint) - assert.Len(t, config.Deployments, 1) - - agent, err := config.Validate() - require.NoError(t, err) - assert.Equal(t, "basic-agent", agent.Name) - assert.Equal(t, deployModeRuntime, agent.deployMode()) - require.NotNil(t, agent.Runtime) - assert.Equal(t, "python", agent.Runtime.Stack) - assert.Equal(t, "gpt-4.1-mini", agent.Env["FOUNDRY_MODEL_DEPLOYMENT_NAME"]) -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go deleted file mode 100644 index 68978e0ff98..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve.go +++ /dev/null @@ -1,232 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package project - -import ( - "context" - "fmt" - "net/url" - "strings" - - "azureaiagent/internal/exterrors" - "azureaiagent/internal/pkg/azure" - - "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" - "github.com/azure/azure-dev/cli/azd/pkg/azdext" -) - -// foundryProjectResourceType is the ARM resource type for a Foundry project. -const foundryProjectResourceType = "Microsoft.CognitiveServices/accounts/projects" - -// foundryEndpointHostSuffix is the host suffix of a Foundry project endpoint. -const foundryEndpointHostSuffix = ".services.ai.azure.com" - -// validateFoundryEndpoint enforces the transport rules every Foundry data-plane -// caller relies on: a non-empty https URL on a recognized Foundry host with no -// explicit port. Rejecting http, foreign hosts, and ports up front avoids -// sending credentials to an unexpected endpoint and catches a partially -// expanded ${VAR} that would otherwise leave an invalid host. It returns the -// parsed URL so callers can extract additional structure without re-parsing. -func validateFoundryEndpoint(endpoint string) (*url.URL, error) { - trimmed := strings.TrimSpace(endpoint) - if trimmed == "" { - return nil, fmt.Errorf("endpoint is empty") - } - - parsed, err := url.Parse(trimmed) - if err != nil { - return nil, fmt.Errorf("invalid endpoint %q: %w", endpoint, err) - } - if !strings.EqualFold(parsed.Scheme, "https") { - return nil, fmt.Errorf("endpoint %q must use https", endpoint) - } - - host := parsed.Hostname() - if !strings.HasSuffix(strings.ToLower(host), foundryEndpointHostSuffix) { - return nil, fmt.Errorf("endpoint host %q is not a Foundry project endpoint", host) - } - if parsed.Port() != "" { - return nil, fmt.Errorf("endpoint %q must not include a port", endpoint) - } - - return parsed, nil -} - -// parseFoundryEndpoint extracts the account and project names from a Foundry -// project endpoint of the form -// https://.services.ai.azure.com/api/projects/. -func parseFoundryEndpoint(endpoint string) (account string, project string, err error) { - parsed, err := validateFoundryEndpoint(endpoint) - if err != nil { - return "", "", err - } - - host := parsed.Hostname() - account = host[:len(host)-len(foundryEndpointHostSuffix)] - if account == "" { - return "", "", fmt.Errorf("endpoint %q is missing the account name", endpoint) - } - - // Path is /api/projects/; take the segment after "projects". - segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") - for i := 0; i+1 < len(segments); i++ { - if segments[i] == "projects" && segments[i+1] != "" { - project = segments[i+1] - break - } - } - if project == "" { - return "", "", fmt.Errorf("endpoint %q is missing the project name", endpoint) - } - - return account, project, nil -} - -// resolveFoundryProjectIDFromEndpoint resolves the ARM resource ID of a Foundry -// project from its data-plane endpoint by listing the Foundry projects in the -// subscription and matching the account and project names. It enables the -// `endpoint:` path (design spec #8590 §1.4): connect to an existing project -// without provisioning, so `azd deploy` can run without AZURE_AI_PROJECT_ID. -func resolveFoundryProjectIDFromEndpoint( - ctx context.Context, - credential azcore.TokenCredential, - subscriptionID string, - endpoint string, -) (string, error) { - account, project, err := parseFoundryEndpoint(endpoint) - if err != nil { - return "", err - } - - client, err := armresources.NewClient(subscriptionID, credential, azure.NewArmClientOptions()) - if err != nil { - return "", fmt.Errorf("failed to create resources client: %w", err) - } - - pager := client.NewListPager(&armresources.ClientListOptions{ - Filter: new(fmt.Sprintf("resourceType eq '%s'", foundryProjectResourceType)), - }) - for pager.More() { - page, err := pager.NextPage(ctx) - if err != nil { - return "", fmt.Errorf("failed to list Foundry projects: %w", err) - } - for _, resource := range page.Value { - if resource == nil || resource.ID == nil { - continue - } - parsed, err := arm.ParseResourceID(*resource.ID) - if err != nil || parsed.Parent == nil { - continue - } - if strings.EqualFold(parsed.Parent.Name, account) && strings.EqualFold(parsed.Name, project) { - return *resource.ID, nil - } - } - } - - return "", fmt.Errorf( - "no Foundry project matching endpoint %q was found in subscription %s", endpoint, subscriptionID) -} - -// resolveProjectFromEndpoint connects to an existing Foundry project when the -// service sets `endpoint:` but no project was provisioned. It resolves the -// project's ARM resource ID from the endpoint and persists it as -// AZURE_AI_PROJECT_ID, so the shared deploy machinery (GetTargetResource, -// finalizeDeploy) works without `azd provision` (design spec #8590 §1.4). -func (p *FoundryServiceTargetProvider) resolveProjectFromEndpoint(ctx context.Context) error { - // Already provisioned or previously resolved: nothing to do. - existing, err := p.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: p.agent.env.Name, - Key: "AZURE_AI_PROJECT_ID", - }) - if err == nil && existing.Value != "" { - return nil - } - - endpoint := p.resolveEndpoint(ctx) - if endpoint == "" { - // No endpoint and no project ID: leave resolution to provision. The - // deploy path surfaces an actionable error if neither is present. - return nil - } - - subscriptionID, err := p.subscriptionID(ctx) - if err != nil { - return err - } - - projectID, err := resolveFoundryProjectIDFromEndpoint(ctx, p.agent.credential, subscriptionID, endpoint) - if err != nil { - return exterrors.Dependency( - exterrors.CodeMissingAiProjectId, - fmt.Sprintf("failed to resolve the Foundry project from endpoint %q: %s", endpoint, err), - "verify the 'endpoint:' on the microsoft.foundry service points at an existing project "+ - "you can access, or run 'azd provision'", - ) - } - - if _, err := p.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: p.agent.env.Name, - Key: "AZURE_AI_PROJECT_ID", - Value: projectID, - }); err != nil { - return fmt.Errorf("failed to persist AZURE_AI_PROJECT_ID: %w", err) - } - - // Persist the resolved endpoint so that Endpoints() and azd show work after a - // deploy without provision. Without this, FOUNDRY_PROJECT_ENDPOINT is only set - // in-memory during Deploy and is absent from the env on the next command. - if _, err := p.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: p.agent.env.Name, - Key: "FOUNDRY_PROJECT_ENDPOINT", - Value: endpoint, - }); err != nil { - return fmt.Errorf("failed to persist FOUNDRY_PROJECT_ENDPOINT: %w", err) - } - - return nil -} - -// resolveEndpoint returns the Foundry project endpoint to connect to, preferring -// the service `endpoint:` field (with ${VAR} expansion, since core does not -// expand AdditionalProperties) and falling back to the FOUNDRY_PROJECT_ENDPOINT -// azd environment value. -func (p *FoundryServiceTargetProvider) resolveEndpoint(ctx context.Context) string { - if p.config != nil && p.config.Endpoint != "" { - azdEnv, _ := p.environmentValues(ctx) - expanded, err := ExpandEnv(p.config.Endpoint, func(name string) string { return azdEnv[name] }) - if err == nil && strings.TrimSpace(expanded) != "" { - return expanded - } - return p.config.Endpoint - } - - resp, err := p.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: p.agent.env.Name, - Key: "FOUNDRY_PROJECT_ENDPOINT", - }) - if err != nil { - return "" - } - return resp.Value -} - -// subscriptionID reads AZURE_SUBSCRIPTION_ID from the active azd environment. -func (p *FoundryServiceTargetProvider) subscriptionID(ctx context.Context) (string, error) { - resp, err := p.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: p.agent.env.Name, - Key: "AZURE_SUBSCRIPTION_ID", - }) - if err != nil || resp.Value == "" { - return "", exterrors.Dependency( - exterrors.CodeMissingAzureSubscription, - "AZURE_SUBSCRIPTION_ID is required to resolve the Foundry project from 'endpoint:'", - "run 'azd env set AZURE_SUBSCRIPTION_ID ' or 'azd provision'", - ) - } - return resp.Value, nil -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve_test.go deleted file mode 100644 index f57be5cb9ab..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_project_resolve_test.go +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package project - -import "testing" - -func TestParseFoundryEndpoint(t *testing.T) { - tests := []struct { - name string - endpoint string - wantAccount string - wantProject string - wantErr bool - }{ - { - name: "standard endpoint", - endpoint: "https://my-account.services.ai.azure.com/api/projects/my-project", - wantAccount: "my-account", - wantProject: "my-project", - }, - { - name: "trailing slash", - endpoint: "https://acct.services.ai.azure.com/api/projects/proj/", - wantAccount: "acct", - wantProject: "proj", - }, - { - name: "uppercase host", - endpoint: "https://Acct.Services.AI.Azure.Com/api/projects/Proj", - wantAccount: "Acct", - wantProject: "Proj", - }, - { - name: "empty", - endpoint: "", - wantErr: true, - }, - { - name: "non-foundry host", - endpoint: "https://example.com/api/projects/proj", - wantErr: true, - }, - { - name: "missing project", - endpoint: "https://acct.services.ai.azure.com/api/projects", - wantErr: true, - }, - { - name: "missing project segment", - endpoint: "https://acct.services.ai.azure.com/", - wantErr: true, - }, - { - name: "http scheme rejected", - endpoint: "http://acct.services.ai.azure.com/api/projects/proj", - wantErr: true, - }, - { - name: "explicit port rejected", - endpoint: "https://acct.services.ai.azure.com:443/api/projects/proj", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - account, project, err := parseFoundryEndpoint(tt.endpoint) - if tt.wantErr { - if err == nil { - t.Fatalf("parseFoundryEndpoint(%q) expected error, got none", tt.endpoint) - } - return - } - if err != nil { - t.Fatalf("parseFoundryEndpoint(%q) unexpected error: %v", tt.endpoint, err) - } - if account != tt.wantAccount { - t.Errorf("account = %q, want %q", account, tt.wantAccount) - } - if project != tt.wantProject { - t.Errorf("project = %q, want %q", project, tt.wantProject) - } - }) - } -} - -func TestValidateFoundryEndpoint(t *testing.T) { - tests := []struct { - name string - endpoint string - wantErr bool - }{ - {name: "valid project endpoint", endpoint: "https://acct.services.ai.azure.com/api/projects/proj"}, - {name: "valid without path", endpoint: "https://acct.services.ai.azure.com"}, - {name: "empty", endpoint: "", wantErr: true}, - {name: "http scheme", endpoint: "http://acct.services.ai.azure.com", wantErr: true}, - {name: "foreign host", endpoint: "https://evil.example.com", wantErr: true}, - {name: "explicit port", endpoint: "https://acct.services.ai.azure.com:8443", wantErr: true}, - {name: "partially expanded var", endpoint: "https://${ACCOUNT}/api/projects/proj", wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := validateFoundryEndpoint(tt.endpoint) - if tt.wantErr && err == nil { - t.Fatalf("validateFoundryEndpoint(%q) expected error, got none", tt.endpoint) - } - if !tt.wantErr && err != nil { - t.Fatalf("validateFoundryEndpoint(%q) unexpected error: %v", tt.endpoint, err) - } - }) - } -} 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 bfd6b3ccaac..bd7fb81865b 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 @@ -1501,7 +1501,7 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(ctx context.Context, serv // zipSourceDir creates a ZIP archive of srcDir honoring .agentignore, writes it to a // temp file, and computes its SHA-256. It returns the temp file path and SHA-256 hex -// string. Shared by the azure.ai.agent and microsoft.foundry code-deploy packaging paths. +// string. func zipSourceDir(ctx context.Context, srcDir string) (string, string, error) { // Load .agentignore (or use defaults if no file exists) ignoreMatcher, err := newAgentIgnoreMatcher(ctx, srcDir) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go deleted file mode 100644 index 9b60186104a..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_foundry.go +++ /dev/null @@ -1,449 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package project - -import ( - "context" - "errors" - "fmt" - "net/http" - "os" - - "azureaiagent/internal/exterrors" - "azureaiagent/internal/pkg/agents/agent_api" - "azureaiagent/internal/pkg/agents/agent_yaml" - "azureaiagent/internal/pkg/paths" - - "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/azidentity" - "github.com/azure/azure-dev/cli/azd/pkg/azdext" -) - -// Ensure FoundryServiceTargetProvider implements the ServiceTargetProvider interface. -var _ azdext.ServiceTargetProvider = &FoundryServiceTargetProvider{} - -// FoundryServiceTargetProvider implements the service target for `host: microsoft.foundry`. -// -// A single service stands for a whole Foundry project. This foundation supports a -// single hosted agent end-to-end (prebuilt image or code-deploy runtime) by mapping -// the inline agent definition onto the existing agent deploy machinery. Multi-agent -// fan-out, container (docker) builds, and data-plane reconcile (deployments, -// connections, toolboxes, skills, routines) are intentionally out of scope here and -// land in follow-up work (design spec #8590 §2.6, §2.8). -type FoundryServiceTargetProvider struct { - azdClient *azdext.AzdClient - - // agent is an AgentServiceTargetProvider reused for shared auth setup and the - // low-level create/poll/finalize helpers, since the deploy primitives are - // identical to the azure.ai.agent host. - agent *AgentServiceTargetProvider - - config *FoundryProjectConfig - hostedAgent FoundryAgent - projectRoot string - serviceConfig *azdext.ServiceConfig - initialized bool -} - -// NewFoundryServiceTargetProvider creates a new FoundryServiceTargetProvider instance. -func NewFoundryServiceTargetProvider(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { - return &FoundryServiceTargetProvider{ - azdClient: azdClient, - agent: &AgentServiceTargetProvider{azdClient: azdClient}, - } -} - -// Initialize binds the inline Foundry configuration, validates the single supported -// hosted agent, resolves the project root, and sets up authentication. -func (p *FoundryServiceTargetProvider) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { - if p.initialized { - return nil - } - - p.serviceConfig = serviceConfig - p.agent.serviceConfig = serviceConfig - - // The Foundry keys are top-level service properties carried on - // AdditionalProperties (not under `config:`), per design spec §2.1. - var config *FoundryProjectConfig - if err := UnmarshalStruct(serviceConfig.AdditionalProperties, &config); err != nil { - return exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("failed to parse microsoft.foundry service config: %s", err), - "check the Foundry service definition in azure.yaml", - ) - } - if config == nil { - config = &FoundryProjectConfig{} - } - p.config = config - - hostedAgent, err := config.Validate() - if err != nil { - return err - } - p.hostedAgent = hostedAgent - - // Resolve the project root (the directory holding azure.yaml). Agent `project` - // paths resolve relative to it. - proj, err := p.azdClient.Project().Get(ctx, nil) - if err != nil { - return exterrors.Dependency( - exterrors.CodeProjectNotFound, - fmt.Sprintf("failed to get project: %s", err), - "run 'azd init' to initialize your project", - ) - } - p.projectRoot = proj.Project.Path - - // Resolve environment, subscription, tenant, and credential. - if err := p.agent.ensureEnv(ctx); err != nil { - return err - } - azdEnvClient := p.azdClient.Environment() - subResp, err := azdEnvClient.GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: p.agent.env.Name, - Key: "AZURE_SUBSCRIPTION_ID", - }) - if err != nil { - return fmt.Errorf("failed to get AZURE_SUBSCRIPTION_ID: %w", err) - } - subscriptionId := subResp.Value - if subscriptionId == "" { - return exterrors.Dependency( - exterrors.CodeMissingAzureSubscription, - "AZURE_SUBSCRIPTION_ID is required: environment variable was not found in the current azd environment", - "run 'azd env get-values' to verify environment values, or initialize/project-bind "+ - "with 'azd ai agent init --project-id ...'", - ) - } - tenantResp, err := p.azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ - SubscriptionId: subscriptionId, - }) - if err != nil { - return exterrors.Auth( - exterrors.CodeTenantLookupFailed, - fmt.Sprintf("failed to get tenant ID for subscription %s: %s", subscriptionId, err), - "verify your Azure login with 'azd auth login' and that you have access to this subscription", - ) - } - p.agent.tenantId = tenantResp.TenantId - cred, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: p.agent.tenantId, - AdditionallyAllowedTenants: []string{"*"}, - }) - if err != nil { - return exterrors.Auth( - exterrors.CodeCredentialCreationFailed, - fmt.Sprintf("failed to create Azure credential: %s", err), - "run 'azd auth login' to authenticate", - ) - } - p.agent.credential = cred - - // Connect to an existing project via `endpoint:` when no project was - // provisioned, so deploy can run without `azd provision` (spec §1.4). - if err := p.resolveProjectFromEndpoint(ctx); err != nil { - return err - } - - p.initialized = true - return nil -} - -// Endpoints returns the deployed agent's endpoints. Delegates to the shared -// implementation, which reads the per-service AGENT__* environment values -// written during Deploy. -func (p *FoundryServiceTargetProvider) Endpoints( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - targetResource *azdext.TargetResource, -) ([]string, error) { - return p.agent.Endpoints(ctx, serviceConfig, targetResource) -} - -// GetTargetResource resolves the ARM resource for the Foundry project. Delegates to -// the shared implementation, which resolves the project from AZURE_AI_PROJECT_ID. -func (p *FoundryServiceTargetProvider) GetTargetResource( - ctx context.Context, - subscriptionId string, - serviceConfig *azdext.ServiceConfig, - defaultResolver func() (*azdext.TargetResource, error), -) (*azdext.TargetResource, error) { - return p.agent.GetTargetResource(ctx, subscriptionId, serviceConfig, defaultResolver) -} - -// Package builds the deploy artifact for the single hosted agent. Code-deploy -// (runtime) agents are zipped from their `project` directory; prebuilt-image agents -// need no packaging. -func (p *FoundryServiceTargetProvider) Package( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - serviceContext *azdext.ServiceContext, - progress azdext.ProgressReporter, -) (*azdext.ServicePackageResult, error) { - if p.hostedAgent.deployMode() != deployModeRuntime { - progress("Using pre-built container image, skipping package") - return &azdext.ServicePackageResult{}, nil - } - - srcDir, err := paths.JoinAllowRoot(p.projectRoot, p.hostedAgent.Project) - if err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("invalid agent project path %q: %s", p.hostedAgent.Project, err), - "set 'project' to a directory within the project root", - ) - } - - progress("Packaging code") - zipPath, sha256Hex, err := zipSourceDir(ctx, srcDir) - if err != nil { - return nil, exterrors.Internal(exterrors.OpContainerPackage, fmt.Sprintf("code packaging failed: %s", err)) - } - - return &azdext.ServicePackageResult{ - Artifacts: []*azdext.Artifact{ - { - Kind: azdext.ArtifactKind_ARTIFACT_KIND_ARCHIVE, - Location: zipPath, - LocationKind: azdext.LocationKind_LOCATION_KIND_LOCAL, - Metadata: map[string]string{ - "type": "code-zip", - "sha256": sha256Hex, - }, - }, - }, - }, nil -} - -// Publish is a no-op for the supported deploy modes: prebuilt images are already -// remote, and code-deploy uploads its ZIP during Deploy. -func (p *FoundryServiceTargetProvider) 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 posts the single hosted agent to Foundry via CreateAgentVersion (image) or -// a ZIP code deploy (runtime), then polls until the version is active and registers -// the agent's environment values. -func (p *FoundryServiceTargetProvider) Deploy( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - serviceContext *azdext.ServiceContext, - targetResource *azdext.TargetResource, - progress azdext.ProgressReporter, -) (*azdext.ServiceDeployResult, error) { - azdEnv, err := p.environmentValues(ctx) - if err != nil { - return nil, err - } - - // An explicit endpoint: on the service points at an existing project; use it as - // the deploy endpoint when provision did not write FOUNDRY_PROJECT_ENDPOINT. - // Expand ${VAR} references so values like ${MY_ENDPOINT} resolve correctly. - if azdEnv["FOUNDRY_PROJECT_ENDPOINT"] == "" && p.config.Endpoint != "" { - endpoint := p.config.Endpoint - if expanded, err := ExpandEnv(p.config.Endpoint, func(name string) string { return azdEnv[name] }); err == nil && expanded != "" { - endpoint = expanded - } - azdEnv["FOUNDRY_PROJECT_ENDPOINT"] = endpoint - } - if azdEnv["FOUNDRY_PROJECT_ENDPOINT"] == "" { - return nil, exterrors.Dependency( - exterrors.CodeMissingAiProjectEndpoint, - "FOUNDRY_PROJECT_ENDPOINT is required: environment variable was not found in the current azd environment", - "run 'azd provision', or set 'endpoint:' on the microsoft.foundry service to use an existing project", - ) - } - - // Reject an insecure or non-Foundry endpoint (http, foreign host, explicit - // port, or a partially expanded ${VAR}) before using it to construct an - // authenticated AgentClient. - if _, err := validateFoundryEndpoint(azdEnv["FOUNDRY_PROJECT_ENDPOINT"]); err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("FOUNDRY_PROJECT_ENDPOINT is not a valid Foundry project endpoint: %v", err), - "set 'endpoint:' (or FOUNDRY_PROJECT_ENDPOINT) to an https Foundry project URL, "+ - "e.g. https://.services.ai.azure.com/api/projects/", - ) - } - - request, protocols, err := p.buildAgentRequest(azdEnv) - if err != nil { - return nil, err - } - - var agentVersion *agent_api.AgentVersionObject - if p.hostedAgent.deployMode() == deployModeRuntime { - agentVersion, err = p.deployCodeAgent(ctx, serviceContext, progress, request, azdEnv) - } else { - progress("Creating agent") - agentVersion, err = p.agent.createAgent(ctx, request, azdEnv) - } - if err != nil { - return nil, err - } - - // Poll until the agent version is active. - if agentVersion.Status != "active" { - agentClient := agent_api.NewAgentClient(azdEnv["FOUNDRY_PROJECT_ENDPOINT"], p.agent.credential) - polled, pollErr := p.agent.waitForAgentActive(ctx, agentClient, request.Name, agentVersion.Version, progress) - if pollErr != nil { - return nil, pollErr - } - agentVersion = polled - } else { - fmt.Fprintf(os.Stderr, "Agent version %s is already active.\n", agentVersion.Version) - } - - return p.agent.finalizeDeploy(ctx, progress, serviceConfig, azdEnv, agentVersion, protocols) -} - -// environmentValues returns the current azd environment values as a map. -func (p *FoundryServiceTargetProvider) environmentValues(ctx context.Context) (map[string]string, error) { - resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ - Name: p.agent.env.Name, - }) - if err != nil { - return nil, exterrors.Dependency( - exterrors.CodeEnvironmentValuesFailed, - fmt.Sprintf("failed to get environment values: %s", err), - "run 'azd env get-values' to verify environment state", - ) - } - - azdEnv := make(map[string]string, len(resp.KeyValues)) - for _, kval := range resp.KeyValues { - azdEnv[kval.Key] = kval.Value - } - return azdEnv, nil -} - -// buildAgentRequest maps the inline hosted agent onto a CreateAgentRequest, resolving -// env values and defaulting protocols. It returns the request plus the protocol list -// used for endpoint registration. -func (p *FoundryServiceTargetProvider) buildAgentRequest( - azdEnv map[string]string, -) (*agent_api.CreateAgentRequest, []agent_yaml.ProtocolVersionRecord, error) { - containerAgent, err := p.hostedAgent.toContainerAgent() - if err != nil { - return nil, nil, err - } - - // Default to the "responses" protocol when none is specified. - if len(containerAgent.Protocols) == 0 { - containerAgent.Protocols = []agent_yaml.ProtocolVersionRecord{ - {Protocol: string(agent_api.AgentProtocolResponses), Version: "1.0.0"}, - } - } - - options := []agent_yaml.AgentBuildOption{} - if env := p.hostedAgent.resolvedEnv(azdEnv); len(env) > 0 { - options = append(options, agent_yaml.WithEnvironmentVariables(env)) - } - if p.hostedAgent.Container != nil && p.hostedAgent.Container.Resources != nil { - if cpu := p.hostedAgent.Container.Resources.Cpu; cpu != "" { - options = append(options, agent_yaml.WithCPU(cpu)) - } - if memory := p.hostedAgent.Container.Resources.Memory; memory != "" { - options = append(options, agent_yaml.WithMemory(memory)) - } - } - if p.hostedAgent.deployMode() == deployModeImage { - options = append(options, agent_yaml.WithImageURL(p.hostedAgent.Image)) - } - - request, err := agent_yaml.CreateAgentAPIRequestFromDefinition(containerAgent, options...) - if err != nil { - return nil, nil, exterrors.Validation( - exterrors.CodeInvalidAgentRequest, - fmt.Sprintf("failed to build agent request: %s", err), - "verify the agent definition in azure.yaml", - ) - } - applyAgentMetadata(request) - - return request, containerAgent.Protocols, nil -} - -// deployCodeAgent performs a ZIP code deploy for a runtime-mode hosted agent, -// creating the agent when absent and updating it (new version) when present. -func (p *FoundryServiceTargetProvider) deployCodeAgent( - ctx context.Context, - serviceContext *azdext.ServiceContext, - progress azdext.ProgressReporter, - request *agent_api.CreateAgentRequest, - azdEnv map[string]string, -) (*agent_api.AgentVersionObject, error) { - var zipPath, sha256Hex string - for _, artifact := range serviceContext.Package { - if artifact.Metadata != nil && artifact.Metadata["type"] == "code-zip" { - zipPath = artifact.Location - sha256Hex = artifact.Metadata["sha256"] - break - } - } - if zipPath == "" { - return nil, exterrors.Dependency( - exterrors.CodeMissingCodeZipArtifact, - "code ZIP artifact not found: no code-zip artifact was found in service package artifacts", - "run 'azd package' to produce the code ZIP artifact", - ) - } - - zipData, err := os.ReadFile(zipPath) //nolint:gosec // zipPath comes from the artifact location set during packaging - if err != nil { - return nil, fmt.Errorf("failed to read ZIP artifact: %w", err) - } - defer os.Remove(zipPath) - - versionRequest := &agent_api.CreateAgentVersionRequest{ - Description: request.Description, - Metadata: request.Metadata, - Definition: request.Definition, - } - - agentClient := agent_api.NewAgentClient(azdEnv["FOUNDRY_PROJECT_ENDPOINT"], p.agent.credential) - - progress("Creating agent") - _, getErr := agentClient.GetAgent(ctx, request.Name, agent_api.AgentEndpointAPIVersion) - - var agentResp *agent_api.AgentObject - if getErr != nil { - // Only fall back to create on 404; propagate other errors (auth, 5xx, network). - if respErr, ok := errors.AsType[*azcore.ResponseError](getErr); !ok || respErr.StatusCode != http.StatusNotFound { - return nil, fmt.Errorf("failed to check if agent exists: %w", getErr) - } - fmt.Fprintf(os.Stderr, "Creating new agent: %s\n", request.Name) - agentResp, err = agentClient.CreateAgentFromZip( - ctx, request.Name, versionRequest, zipData, sha256Hex, agent_api.AgentEndpointAPIVersion, - ) - if err != nil { - return nil, exterrors.Internal( - exterrors.CodeAgentCreateFailed, - fmt.Sprintf("failed to create agent from ZIP: %s; check the agent definition and try again", err), - ) - } - } else { - writeExistingAgentVersionWarning(request.Name) - agentResp, err = agentClient.UpdateAgentFromZip( - ctx, request.Name, versionRequest, zipData, sha256Hex, agent_api.AgentEndpointAPIVersion, - ) - if err != nil { - return nil, exterrors.Internal( - exterrors.CodeAgentCreateFailed, - fmt.Sprintf("failed to update agent from ZIP: %s; check the agent definition and try again", err), - ) - } - } - - return &agentResp.Versions.Latest, nil -} From 74b448f8b841f5653e61eae2f44c237b638813b9 Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 22 Jun 2026 21:03:00 +0800 Subject: [PATCH 08/50] fix: fall back to bundled agent config for pre-split azure.yaml --- .../extensions/azure.ai.agents/CHANGELOG.md | 2 +- .../internal/cmd/resource_services.go | 61 ++++++++++++++++++- .../internal/cmd/resource_services_test.go | 59 ++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index c8bf7a344df..b6df9c77fd0 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -4,7 +4,7 @@ ### 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:`. Provisioning is unchanged: the agent extension re-sources deployments, connections, and toolboxes from the sibling services when setting provisioning environment variables and creating toolsets. +- `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:`. 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`. ## 0.1.41-preview (2026-06-19) 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 4f3ddb5eb17..69b186ccecb 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 @@ -181,6 +181,10 @@ func sanitizeServiceName(name string) string { // azure.ai.project services so provisioning handlers can source them from the // sibling project service instead of the agent service config. Services are // visited in sorted name order so serialized env-var output stays stable. +// +// Falls back to the deployments bundled on the agent service when no project +// service carries any, so an azure.yaml written before the per-resource split +// still provisions without re-running init. func collectProjectDeployments(services map[string]*azdext.ServiceConfig) ([]project.Deployment, error) { var out []project.Deployment for _, svc := range sortedServices(services) { @@ -195,11 +199,23 @@ func collectProjectDeployments(services map[string]*azdext.ServiceConfig) ([]pro out = append(out, cfg.Deployments...) } } + if len(out) > 0 { + return out, nil + } + legacy, err := collectLegacyAgentConfigs(services) + if err != nil { + return nil, err + } + for _, cfg := range legacy { + out = append(out, cfg.Deployments...) + } return out, nil } // collectConnections gathers the connections declared across all -// azure.ai.connection services. +// azure.ai.connection services. Falls back to the connections bundled on the +// agent service when no connection service carries any, so a pre-split +// azure.yaml still provisions without re-running init. func collectConnections(services map[string]*azdext.ServiceConfig) ([]project.Connection, error) { var out []project.Connection for _, svc := range sortedServices(services) { @@ -214,11 +230,23 @@ func collectConnections(services map[string]*azdext.ServiceConfig) ([]project.Co out = append(out, *conn) } } + if len(out) > 0 { + return out, nil + } + legacy, err := collectLegacyAgentConfigs(services) + if err != nil { + return nil, err + } + for _, cfg := range legacy { + out = append(out, cfg.Connections...) + } return out, nil } // collectToolboxes gathers the toolboxes declared across all azure.ai.toolbox -// services. +// services. Falls back to the toolboxes bundled on the agent service when no +// toolbox service carries any, so a pre-split azure.yaml still provisions +// without re-running init. func collectToolboxes(services map[string]*azdext.ServiceConfig) ([]project.Toolbox, error) { var out []project.Toolbox for _, svc := range sortedServices(services) { @@ -233,6 +261,16 @@ func collectToolboxes(services map[string]*azdext.ServiceConfig) ([]project.Tool out = append(out, *toolbox) } } + if len(out) > 0 { + return out, nil + } + legacy, err := collectLegacyAgentConfigs(services) + if err != nil { + return nil, err + } + for _, cfg := range legacy { + out = append(out, cfg.Toolboxes...) + } return out, nil } @@ -241,7 +279,24 @@ func collectToolboxes(services map[string]*azdext.ServiceConfig) ([]project.Tool // configuration), so toolbox enrichment still needs them alongside the // connections sourced from azure.ai.connection services. func collectAgentToolConnections(services map[string]*azdext.ServiceConfig) ([]project.ToolConnection, error) { + configs, err := collectLegacyAgentConfigs(services) + if err != nil { + return nil, err + } var out []project.ToolConnection + for _, cfg := range configs { + out = append(out, cfg.ToolConnections...) + } + return out, nil +} + +// collectLegacyAgentConfigs parses the bundled ServiceTargetAgentConfig from +// every agent service, in sorted name order. Tool connections always live here; +// projects created before the per-resource split also carry their deployments, +// connections, and toolboxes here rather than in sibling azure.ai. +// services, so the collectors fall back to these when no sibling service exists. +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 { continue @@ -251,7 +306,7 @@ func collectAgentToolConnections(services map[string]*azdext.ServiceConfig) ([]p return nil, fmt.Errorf("parsing agent service %q config: %w", svc.Name, err) } if cfg != nil { - out = append(out, cfg.ToolConnections...) + out = append(out, cfg) } } return out, 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 4a95fbddd04..c09b4896718 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 @@ -156,3 +156,62 @@ func TestCollectHelpers_EmptyAndNilConfigs(t *testing.T) { require.NoError(t, err) assert.Empty(t, toolboxes) } + +// TestCollect_FallbackToBundledAgentConfig verifies that a pre-split azure.yaml +// -- deployments, connections, and toolboxes bundled on the agent service with +// no sibling azure.ai. services -- still yields those resources, so +// existing projects provision without re-running init. +func TestCollect_FallbackToBundledAgentConfig(t *testing.T) { + t.Parallel() + + bundled := &project.ServiceTargetAgentConfig{ + Deployments: []project.Deployment{{Name: "gpt-4o", Model: project.DeploymentModel{Name: "gpt-4o"}}}, + Connections: []project.Connection{{Name: "conn", Category: "ApiKey"}}, + Toolboxes: []project.Toolbox{{Name: "tb", Tools: []map[string]any{{"type": "mcp"}}}}, + } + svc := mustMarshalConfig(t, bundled) + svc.Name = "my-agent" + svc.Host = AiAgentHost + services := map[string]*azdext.ServiceConfig{"my-agent": svc} + + deployments, err := collectProjectDeployments(services) + require.NoError(t, err) + require.Len(t, deployments, 1) + assert.Equal(t, "gpt-4o", deployments[0].Name) + + connections, err := collectConnections(services) + require.NoError(t, err) + require.Len(t, connections, 1) + assert.Equal(t, "conn", connections[0].Name) + + toolboxes, err := collectToolboxes(services) + require.NoError(t, err) + require.Len(t, toolboxes, 1) + assert.Equal(t, "tb", toolboxes[0].Name) +} + +// TestCollectProjectDeployments_SiblingWinsOverBundled verifies the sibling +// azure.ai.project service takes precedence: the fallback to bundled agent +// deployments only applies when no project service carries any. +func TestCollectProjectDeployments_SiblingWinsOverBundled(t *testing.T) { + t.Parallel() + + bundled := &project.ServiceTargetAgentConfig{ + Deployments: []project.Deployment{{Name: "legacy", Model: project.DeploymentModel{Name: "legacy"}}}, + } + agentSvc := mustMarshalConfig(t, bundled) + agentSvc.Name = "my-agent" + agentSvc.Host = AiAgentHost + + services := map[string]*azdext.ServiceConfig{ + "my-agent": agentSvc, + "ai-project": projectService( + t, "ai-project", project.Deployment{Name: "gpt-4o", Model: project.DeploymentModel{Name: "gpt-4o"}}, + ), + } + + deployments, err := collectProjectDeployments(services) + require.NoError(t, err) + require.Len(t, deployments, 1) + assert.Equal(t, "gpt-4o", deployments[0].Name) +} From 391faf70979f57297893aeb31d16fd266dd5aec0 Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 22 Jun 2026 22:01:56 +0800 Subject: [PATCH 09/50] fix: emit ai-project for siblings and detect name collisions --- .../internal/cmd/resource_services.go | 43 ++++++++++++++++++- .../internal/cmd/resource_services_test.go | 22 ++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) 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 69b186ccecb..e0bbf6057b5 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 @@ -49,16 +49,32 @@ func emitResourceServices( ) error { var agentUses []string + // Track every azure.yaml service key we emit so two resource names that + // sanitize to the same key (e.g. "my conn" and "myconn") fail fast instead + // of silently overwriting each other -- AddService overwrites by name. + // Seed it with the agent service name, which the caller adds before this + // runs, so a resource colliding with the agent is caught too. + usedNames := map[string]string{} + if agentServiceName != "" { + usedNames[agentServiceName] = "agent service" + } + // One project service owns the model deployments. Deployments stay an // array on it (there is a single Foundry project and deployments belong - // to it). + // to it). Create it whenever any Foundry resource is emitted -- even with + // no deployments (e.g. "Skip model configuration") -- so connections and + // toolboxes always have a stable ai-project dependency that enforces + // provisioning order. projectServiceName := "" - if len(deployments) > 0 { + if len(deployments) > 0 || len(connections) > 0 || len(toolboxes) > 0 { projectCfg, err := project.MarshalStruct(&project.ServiceTargetAgentConfig{Deployments: deployments}) if err != nil { return fmt.Errorf("marshaling project service config: %w", err) } projectServiceName = aiProjectServiceName + if err := reserveServiceName(usedNames, projectServiceName, "project service"); err != nil { + return err + } if err := addResourceService(ctx, azdClient, projectServiceName, AiProjectHost, projectCfg, nil); err != nil { return err } @@ -78,6 +94,9 @@ func emitResourceServices( if connName == "" { continue } + if err := reserveServiceName(usedNames, connName, fmt.Sprintf("connection %q", conn.Name)); err != nil { + return err + } connCfg, err := project.MarshalStruct(&conn) if err != nil { return fmt.Errorf("marshaling connection service %q config: %w", connName, err) @@ -94,6 +113,9 @@ func emitResourceServices( if toolboxName == "" { continue } + if err := reserveServiceName(usedNames, toolboxName, fmt.Sprintf("toolbox %q", toolbox.Name)); err != nil { + return err + } toolboxCfg, err := project.MarshalStruct(&toolbox) if err != nil { return fmt.Errorf("marshaling toolbox service %q config: %w", toolboxName, err) @@ -177,6 +199,23 @@ func sanitizeServiceName(name string) string { return strings.ReplaceAll(strings.TrimSpace(name), " ", "") } +// reserveServiceName records an azure.yaml service key derived from a Foundry +// resource name, returning an error when two resources sanitize to the same +// key. AddService overwrites by name, so without this a collision would +// silently drop a resource and corrupt the uses: graph; failing fast lets the +// user rename the offending resource. +func reserveServiceName(used map[string]string, name, source string) error { + if existing, ok := used[name]; ok { + return fmt.Errorf( + "resource service name collision: %s and %s both map to azure.yaml service %q; "+ + "rename one so they produce distinct service names", + existing, source, name, + ) + } + used[name] = source + return nil +} + // collectProjectDeployments gathers the model deployments declared across all // azure.ai.project services so provisioning handlers can source them from the // sibling project service instead of the agent service config. Services are 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 c09b4896718..989eaf9fbad 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 @@ -62,6 +62,28 @@ func TestSanitizeServiceName(t *testing.T) { assert.Equal(t, "", sanitizeServiceName(" ")) } +// TestReserveServiceName verifies distinct service keys are accepted and that +// two resources collapsing to the same azure.yaml key (e.g. "my conn" and +// "myconn") fail fast with an actionable collision error instead of silently +// overwriting each other. +func TestReserveServiceName(t *testing.T) { + t.Parallel() + + used := map[string]string{"weatheragent": "agent service"} + require.NoError(t, reserveServiceName(used, "myconn", `connection "my conn"`)) + require.NoError(t, reserveServiceName(used, "toolbox", `toolbox "toolbox"`)) + + err := reserveServiceName(used, "myconn", `connection "myconn"`) + require.Error(t, err) + assert.Contains(t, err.Error(), "collision") + assert.Contains(t, err.Error(), "myconn") + + // A resource colliding with the seeded agent service is also caught. + err = reserveServiceName(used, "weatheragent", `connection "weather agent"`) + require.Error(t, err) + assert.Contains(t, err.Error(), "agent service") +} + // TestCollectProjectDeployments verifies deployments are sourced only from // azure.ai.project services and ignore sibling hosts. func TestCollectProjectDeployments(t *testing.T) { From 48507440b2be604f41c9ebba20cbb4722f23d5fa Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 22 Jun 2026 22:02:03 +0800 Subject: [PATCH 10/50] fix: drop duplicate provisionToolboxes doc comment --- cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 209103bcd12..e1f196b301f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -715,9 +715,6 @@ func populateContainerSettings(ctx context.Context, azdClient *azdext.AzdClient, return nil } -// provisionToolboxes creates or updates Foundry Toolsets for each toolbox -// in the service config. Called during post-provision after the project -// endpoint has been created by Bicep. // 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 From 61126420a11cea06c3a8f181ea5fee9ee1161853 Mon Sep 17 00:00:00 2001 From: huimiu Date: Mon, 22 Jun 2026 23:21:13 +0800 Subject: [PATCH 11/50] feat: register foundry resource hosts in agents extension --- .../extensions/azure.ai.agents/CHANGELOG.md | 2 +- .../extensions/azure.ai.agents/extension.yaml | 9 ++ .../azure.ai.agents/internal/cmd/listen.go | 13 +++ .../project/service_target_resource.go} | 55 ++++----- .../azure.ai.connections/CHANGELOG.md | 4 - .../azure.ai.connections/extension.yaml | 7 +- .../internal/cmd/listen.go | 23 ---- .../azure.ai.connections/internal/cmd/root.go | 4 - .../internal/project/service_target.go | 104 ------------------ .../azure.ai.connections/version.txt | 2 +- .../extensions/azure.ai.projects/CHANGELOG.md | 6 - .../azure.ai.projects/extension.yaml | 7 +- .../azure.ai.projects/internal/cmd/listen.go | 23 ---- .../azure.ai.projects/internal/cmd/root.go | 4 - .../extensions/azure.ai.projects/version.txt | 2 +- .../azure.ai.toolboxes/CHANGELOG.md | 4 - .../azure.ai.toolboxes/extension.yaml | 7 +- .../azure.ai.toolboxes/internal/cmd/listen.go | 23 ---- .../azure.ai.toolboxes/internal/cmd/root.go | 4 - .../internal/project/service_target.go | 104 ------------------ .../extensions/azure.ai.toolboxes/version.txt | 2 +- 21 files changed, 58 insertions(+), 351 deletions(-) rename cli/azd/extensions/{azure.ai.projects/internal/project/service_target.go => azure.ai.agents/internal/project/service_target_resource.go} (52%) delete mode 100644 cli/azd/extensions/azure.ai.connections/internal/cmd/listen.go delete mode 100644 cli/azd/extensions/azure.ai.connections/internal/project/service_target.go delete mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/listen.go delete mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/listen.go delete mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/project/service_target.go diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index b6df9c77fd0..2bc4d5c517e 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -4,7 +4,7 @@ ### 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:`. 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`. +- `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`. ## 0.1.41-preview (2026-06-19) diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index 253d4cf16b7..37089e2c078 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -22,6 +22,15 @@ providers: - name: azure.ai.agent type: service-target description: Deploys agents to the Foundry Agent Service + - name: azure.ai.project + type: service-target + description: Registers Foundry project service entries so azd up/deploy succeed + - name: azure.ai.connection + type: service-target + description: Registers Foundry connection service entries so azd up/deploy succeed + - name: azure.ai.toolbox + type: service-target + description: Registers Foundry toolbox service entries so azd up/deploy succeed - name: microsoft.foundry type: provisioning-provider description: Provisions a Microsoft Foundry project from azure.yaml without an on-disk infra/ directory 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 e1f196b301f..83f2372a949 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -41,6 +41,19 @@ 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) }). diff --git a/cli/azd/extensions/azure.ai.projects/internal/project/service_target.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_resource.go similarity index 52% rename from cli/azd/extensions/azure.ai.projects/internal/project/service_target.go rename to cli/azd/extensions/azure.ai.agents/internal/project/service_target_resource.go index a9b93a9f07a..523984acf9d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/project/service_target.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_resource.go @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -// Package project implements the azd service target for the azure.ai.project host. package project import ( @@ -10,30 +9,34 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) -// ProjectHost is the azd service host served by this extension. It must match -// the provider name declared in extension.yaml. -const ProjectHost = "azure.ai.project" +var _ azdext.ServiceTargetProvider = (*ResourceServiceTargetProvider)(nil) -var _ azdext.ServiceTargetProvider = (*ProjectServiceTargetProvider)(nil) - -// ProjectServiceTargetProvider is a no-op service target for the -// azure.ai.project host. The Foundry project and its model deployments are -// created by Bicep during `azd provision` (orchestrated by the Foundry agents -// extension), so the deploy-time hooks here intentionally do nothing. -// Registering the host is what lets `azd up`/`azd deploy` succeed for project -// service entries that an agent service references via `uses:`. -type ProjectServiceTargetProvider struct { +// 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 } -// NewProjectServiceTargetProvider creates a no-op project service target. -func NewProjectServiceTargetProvider(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { - return &ProjectServiceTargetProvider{azdClient: azdClient} +// 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 *ProjectServiceTargetProvider) Initialize( +func (p *ResourceServiceTargetProvider) Initialize( ctx context.Context, serviceConfig *azdext.ServiceConfig, ) error { @@ -41,8 +44,8 @@ func (p *ProjectServiceTargetProvider) Initialize( return nil } -// Endpoints returns no endpoints; the project service does not expose any. -func (p *ProjectServiceTargetProvider) Endpoints( +// Endpoints returns no endpoints; Foundry resource services do not expose any. +func (p *ResourceServiceTargetProvider) Endpoints( ctx context.Context, serviceConfig *azdext.ServiceConfig, targetResource *azdext.TargetResource, @@ -52,7 +55,7 @@ func (p *ProjectServiceTargetProvider) Endpoints( // 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 *ProjectServiceTargetProvider) GetTargetResource( +func (p *ResourceServiceTargetProvider) GetTargetResource( ctx context.Context, subscriptionId string, serviceConfig *azdext.ServiceConfig, @@ -69,8 +72,8 @@ func (p *ProjectServiceTargetProvider) GetTargetResource( return &azdext.TargetResource{SubscriptionId: subscriptionId}, nil } -// Package is a no-op; there is nothing to build or stage for the project service. -func (p *ProjectServiceTargetProvider) Package( +// 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, @@ -79,8 +82,8 @@ func (p *ProjectServiceTargetProvider) Package( return &azdext.ServicePackageResult{}, nil } -// Publish is a no-op; the project service has no artifacts to publish. -func (p *ProjectServiceTargetProvider) Publish( +// 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, @@ -91,8 +94,8 @@ func (p *ProjectServiceTargetProvider) Publish( return &azdext.ServicePublishResult{}, nil } -// Deploy is a no-op; the project and its deployments are created at provision time by Bicep. -func (p *ProjectServiceTargetProvider) Deploy( +// 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, diff --git a/cli/azd/extensions/azure.ai.connections/CHANGELOG.md b/cli/azd/extensions/azure.ai.connections/CHANGELOG.md index bd008ae134e..3c77b09ab96 100644 --- a/cli/azd/extensions/azure.ai.connections/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.connections/CHANGELOG.md @@ -2,10 +2,6 @@ ## Unreleased -### Features Added - -- Register the `azure.ai.connection` service target. `azd ai agent init` can now write Foundry connections as their own `azure.ai.connection` service entries wired to the agent via `uses:`, and this extension registers the host so `azd up`/`azd deploy` succeed for those entries. Connections continue to be provisioned by Bicep during `azd provision`, so the deploy-time hook is intentionally a no-op. - ## 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 d05f45e921a..0a2c0f2382c 100644 --- a/cli/azd/extensions/azure.ai.connections/extension.yaml +++ b/cli/azd/extensions/azure.ai.connections/extension.yaml @@ -2,18 +2,13 @@ capabilities: - custom-commands - metadata - - service-target-provider 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: Registers Foundry connection service entries so azd up/deploy succeed tags: - ai - connection usage: azd ai connection [options] -version: 0.1.3-preview +version: 0.1.2-preview diff --git a/cli/azd/extensions/azure.ai.connections/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.connections/internal/cmd/listen.go deleted file mode 100644 index 9cf57dbb07f..00000000000 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/listen.go +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "azure.ai.connections/internal/project" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" -) - -// configureExtensionHost registers the azure.ai.connection service target on -// the supplied host. It is passed to azdext.NewListenCommand from the root -// command, which handles the surrounding setup (access token, AzdClient -// creation, and the host.Run lifecycle). -func configureExtensionHost(host *azdext.ExtensionHost) { - azdClient := host.Client() - - // IMPORTANT: the host name must match the provider name in extension.yaml. - host.WithServiceTarget(project.ConnectionHost, func() azdext.ServiceTargetProvider { - return project.NewConnectionServiceTargetProvider(azdClient) - }) -} 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 faf9f559aee..fd5c9c8ea2d 100644 --- a/cli/azd/extensions/azure.ai.connections/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.connections/internal/cmd/root.go @@ -27,10 +27,6 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) rootCmd.AddCommand(newMetadataCommand(rootCmd)) - // Register the azure.ai.connection service target so `azd up`/`azd deploy` - // succeed for connection service entries written by `azd ai agent init`. - rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) - // Register -p / --project-endpoint as a persistent flag inherited by // connection CRUD subcommands (list, show, create, update, delete). rootCmd.PersistentFlags().StringP("project-endpoint", "p", "", diff --git a/cli/azd/extensions/azure.ai.connections/internal/project/service_target.go b/cli/azd/extensions/azure.ai.connections/internal/project/service_target.go deleted file mode 100644 index 7b71536ef6a..00000000000 --- a/cli/azd/extensions/azure.ai.connections/internal/project/service_target.go +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -// Package project implements the azd service target for the azure.ai.connection host. -package project - -import ( - "context" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" -) - -// ConnectionHost is the azd service host served by this extension. It must -// match the provider name declared in extension.yaml. -const ConnectionHost = "azure.ai.connection" - -var _ azdext.ServiceTargetProvider = (*ConnectionServiceTargetProvider)(nil) - -// ConnectionServiceTargetProvider is a no-op service target for the -// azure.ai.connection host. Foundry connections are created by Bicep during -// `azd provision` (orchestrated by the Foundry agents extension), so the -// deploy-time hooks here intentionally do nothing. Registering the host is -// what lets `azd up`/`azd deploy` succeed for connection service entries that -// an agent service references via `uses:`. -type ConnectionServiceTargetProvider struct { - azdClient *azdext.AzdClient - serviceConfig *azdext.ServiceConfig -} - -// NewConnectionServiceTargetProvider creates a no-op connection service target. -func NewConnectionServiceTargetProvider(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { - return &ConnectionServiceTargetProvider{azdClient: azdClient} -} - -// Initialize stores the service configuration; no other setup is required. -func (p *ConnectionServiceTargetProvider) Initialize( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, -) error { - p.serviceConfig = serviceConfig - return nil -} - -// Endpoints returns no endpoints; connections do not expose any. -func (p *ConnectionServiceTargetProvider) Endpoints( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - targetResource *azdext.TargetResource, -) ([]string, error) { - return nil, nil -} - -// GetTargetResource resolves the target resource. Connections have no -// standalone ARM resource, so it delegates to azd's default resolver and -// falls back to a minimal target so the deploy pipeline can proceed. -func (p *ConnectionServiceTargetProvider) 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 connection. -func (p *ConnectionServiceTargetProvider) 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; connections have no artifacts to publish. -func (p *ConnectionServiceTargetProvider) 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 connection is created at provision time by Bicep. -func (p *ConnectionServiceTargetProvider) 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.connections/version.txt b/cli/azd/extensions/azure.ai.connections/version.txt index 18cc3624f44..15b416cc5ea 100644 --- a/cli/azd/extensions/azure.ai.connections/version.txt +++ b/cli/azd/extensions/azure.ai.connections/version.txt @@ -1 +1 @@ -0.1.3-preview +0.1.2-preview diff --git a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md index 80a3b60d887..6c6875ab6dd 100644 --- a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md @@ -1,11 +1,5 @@ # Release History -## Unreleased - -### Features Added - -- Register the `azure.ai.project` service target. `azd ai agent init` can now write the Foundry project (and its model deployments) as its own `azure.ai.project` service entry wired to the agent via `uses:`, and this extension registers the host so `azd up`/`azd deploy` succeed for that entry. The project and its deployments continue to be provisioned by Bicep during `azd provision`, so the deploy-time hook is intentionally a no-op. - ## 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 d6fa1efb3ab..58914fd54c5 100644 --- a/cli/azd/extensions/azure.ai.projects/extension.yaml +++ b/cli/azd/extensions/azure.ai.projects/extension.yaml @@ -2,18 +2,13 @@ capabilities: - custom-commands - metadata - - service-target-provider 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: Registers Foundry project service entries so azd up/deploy succeed tags: - ai - project usage: azd ai project [options] -version: 0.1.1-preview +version: 0.1.0-preview diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/listen.go deleted file mode 100644 index 4ae08ccc2b5..00000000000 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/listen.go +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "azure.ai.projects/internal/project" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" -) - -// configureExtensionHost registers the azure.ai.project service target on the -// supplied host. It is passed to azdext.NewListenCommand from the root command, -// which handles the surrounding setup (access token, AzdClient creation, and -// the host.Run lifecycle). -func configureExtensionHost(host *azdext.ExtensionHost) { - azdClient := host.Client() - - // IMPORTANT: the host name must match the provider name in extension.yaml. - host.WithServiceTarget(project.ProjectHost, func() azdext.ServiceTargetProvider { - return project.NewProjectServiceTargetProvider(azdClient) - }) -} 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 245d798e07f..faa218b61c0 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go @@ -30,9 +30,5 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newProjectUnsetCommand(extCtx)) rootCmd.AddCommand(newProjectShowCommand(extCtx)) - // Register the azure.ai.project service target so `azd up`/`azd deploy` - // succeed for project service entries written by `azd ai agent init`. - rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) - return rootCmd } diff --git a/cli/azd/extensions/azure.ai.projects/version.txt b/cli/azd/extensions/azure.ai.projects/version.txt index 3228017292e..b727e6cbb8a 100644 --- a/cli/azd/extensions/azure.ai.projects/version.txt +++ b/cli/azd/extensions/azure.ai.projects/version.txt @@ -1 +1 @@ -0.1.1-preview \ No newline at end of file +0.1.0-preview \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md b/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md index fbaa770c063..232276e97ad 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md @@ -2,10 +2,6 @@ ## Unreleased -### Features Added - -- Register the `azure.ai.toolbox` service target. `azd ai agent init` can now write Foundry toolboxes as their own `azure.ai.toolbox` service entries wired to the agent via `uses:`, and this extension registers the host so `azd up`/`azd deploy` succeed for those entries. Toolboxes continue to be created via the dataplane API during `azd provision`, so the deploy-time hook is intentionally a no-op. - ## 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 4f1ceae8f2c..16215ae4ab8 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/extension.yaml +++ b/cli/azd/extensions/azure.ai.toolboxes/extension.yaml @@ -2,18 +2,13 @@ capabilities: - custom-commands - metadata - - service-target-provider 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: Registers Foundry toolbox service entries so azd up/deploy succeed tags: - ai - toolbox usage: azd ai toolbox [options] -version: 0.1.2-preview +version: 0.1.1-preview diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/listen.go deleted file mode 100644 index 481a59d592a..00000000000 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/listen.go +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "azure.ai.toolboxes/internal/project" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" -) - -// configureExtensionHost registers the azure.ai.toolbox service target on the -// supplied host. It is passed to azdext.NewListenCommand from the root command, -// which handles the surrounding setup (access token, AzdClient creation, and -// the host.Run lifecycle). -func configureExtensionHost(host *azdext.ExtensionHost) { - azdClient := host.Client() - - // IMPORTANT: the host name must match the provider name in extension.yaml. - host.WithServiceTarget(project.ToolboxHost, func() azdext.ServiceTargetProvider { - return project.NewToolboxServiceTargetProvider(azdClient) - }) -} 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 7a0155f348e..dfcffe5fdd2 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go @@ -54,9 +54,5 @@ to promote a version.`, rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) rootCmd.AddCommand(newMetadataCommand(rootCmd)) - // Register the azure.ai.toolbox service target so `azd up`/`azd deploy` - // succeed for toolbox service entries written by `azd ai agent init`. - rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) - return rootCmd } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/project/service_target.go b/cli/azd/extensions/azure.ai.toolboxes/internal/project/service_target.go deleted file mode 100644 index 3c8c97a8ffa..00000000000 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/project/service_target.go +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -// Package project implements the azd service target for the azure.ai.toolbox host. -package project - -import ( - "context" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" -) - -// ToolboxHost is the azd service host served by this extension. It must match -// the provider name declared in extension.yaml. -const ToolboxHost = "azure.ai.toolbox" - -var _ azdext.ServiceTargetProvider = (*ToolboxServiceTargetProvider)(nil) - -// ToolboxServiceTargetProvider is a no-op service target for the -// azure.ai.toolbox host. Foundry toolboxes are created via the dataplane API -// during `azd provision` (orchestrated by the Foundry agents extension's -// post-provision hook), so the deploy-time hooks here intentionally do -// nothing. Registering the host is what lets `azd up`/`azd deploy` succeed for -// toolbox service entries that an agent service references via `uses:`. -type ToolboxServiceTargetProvider struct { - azdClient *azdext.AzdClient - serviceConfig *azdext.ServiceConfig -} - -// NewToolboxServiceTargetProvider creates a no-op toolbox service target. -func NewToolboxServiceTargetProvider(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { - return &ToolboxServiceTargetProvider{azdClient: azdClient} -} - -// Initialize stores the service configuration; no other setup is required. -func (p *ToolboxServiceTargetProvider) Initialize( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, -) error { - p.serviceConfig = serviceConfig - return nil -} - -// Endpoints returns no endpoints; toolboxes do not expose any. -func (p *ToolboxServiceTargetProvider) Endpoints( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - targetResource *azdext.TargetResource, -) ([]string, error) { - return nil, nil -} - -// GetTargetResource resolves the target resource. Toolboxes have no standalone -// ARM resource, so it delegates to azd's default resolver and falls back to a -// minimal target so the deploy pipeline can proceed. -func (p *ToolboxServiceTargetProvider) 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 toolbox. -func (p *ToolboxServiceTargetProvider) 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; toolboxes have no artifacts to publish. -func (p *ToolboxServiceTargetProvider) 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 toolbox is created at provision time via the dataplane API. -func (p *ToolboxServiceTargetProvider) 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.toolboxes/version.txt b/cli/azd/extensions/azure.ai.toolboxes/version.txt index 15b416cc5ea..9ff8406fee4 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/version.txt +++ b/cli/azd/extensions/azure.ai.toolboxes/version.txt @@ -1 +1 @@ -0.1.2-preview +0.1.1-preview From 5f0e7b9f89e82af316e39577e70c24869ad03cf9 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 11:05:25 +0800 Subject: [PATCH 12/50] feat(agents): always emit ai-project service entry --- .../internal/cmd/resource_services.go | 45 +++--- .../internal/cmd/resource_services_test.go | 130 ++++++++++++++++++ 2 files changed, 149 insertions(+), 26 deletions(-) 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 e0bbf6057b5..ecc5016a227 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 @@ -59,34 +59,27 @@ func emitResourceServices( usedNames[agentServiceName] = "agent service" } - // One project service owns the model deployments. Deployments stay an - // array on it (there is a single Foundry project and deployments belong - // to it). Create it whenever any Foundry resource is emitted -- even with - // no deployments (e.g. "Skip model configuration") -- so connections and - // toolboxes always have a stable ai-project dependency that enforces - // provisioning order. - projectServiceName := "" - if len(deployments) > 0 || len(connections) > 0 || len(toolboxes) > 0 { - projectCfg, err := project.MarshalStruct(&project.ServiceTargetAgentConfig{Deployments: deployments}) - if err != nil { - return fmt.Errorf("marshaling project service config: %w", err) - } - projectServiceName = aiProjectServiceName - if err := reserveServiceName(usedNames, projectServiceName, "project service"); err != nil { - return err - } - if err := addResourceService(ctx, azdClient, projectServiceName, AiProjectHost, projectCfg, nil); err != nil { - return err - } - agentUses = append(agentUses, projectServiceName) + // One project service owns the model deployments and represents the single + // Foundry project the agent targets. It is always emitted -- even with no + // deployments (e.g. "Skip model configuration") -- so every agent has one + // stable ai-project sibling that connections and toolboxes can depend on to + // enforce provisioning order. + projectCfg, err := project.MarshalStruct(&project.ServiceTargetAgentConfig{Deployments: deployments}) + if err != nil { + return fmt.Errorf("marshaling project service config: %w", err) } - - // Connection and toolbox services depend on the project service when one - // exists, so the project is provisioned first. - var siblingUses []string - if projectServiceName != "" { - siblingUses = []string{projectServiceName} + projectServiceName := aiProjectServiceName + if err := reserveServiceName(usedNames, projectServiceName, "project service"); err != nil { + return err } + if err := addResourceService(ctx, azdClient, projectServiceName, AiProjectHost, projectCfg, nil); err != nil { + return err + } + agentUses = append(agentUses, projectServiceName) + + // Connection and toolbox services depend on the project service so the + // project is provisioned first. + siblingUses := []string{projectServiceName} for i := range connections { conn := connections[i] 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 989eaf9fbad..3459f6af91a 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 @@ -4,6 +4,9 @@ package cmd import ( + "context" + "net" + "sync" "testing" "azureaiagent/internal/project" @@ -11,6 +14,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc" ) func mustMarshalConfig[T any](t *testing.T, in *T) *azdext.ServiceConfig { @@ -237,3 +241,129 @@ func TestCollectProjectDeployments_SiblingWinsOverBundled(t *testing.T) { require.Len(t, deployments, 1) assert.Equal(t, "gpt-4o", deployments[0].Name) } + +// recordingProjectServer captures the AddService and SetServiceConfigValue +// calls emitResourceServices makes, so tests can assert on the emitted +// azure.yaml service graph without a real azd host. +type recordingProjectServer struct { + azdext.UnimplementedProjectServiceServer + + mu sync.Mutex + added []*azdext.ServiceConfig + uses map[string][]string +} + +func (s *recordingProjectServer) AddService( + _ context.Context, req *azdext.AddServiceRequest, +) (*azdext.EmptyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.added = append(s.added, req.Service) + return &azdext.EmptyResponse{}, nil +} + +func (s *recordingProjectServer) SetServiceConfigValue( + _ context.Context, req *azdext.SetServiceConfigValueRequest, +) (*azdext.EmptyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.uses == nil { + s.uses = map[string][]string{} + } + if req.Path == "uses" && req.Value != nil { + if list, ok := req.Value.AsInterface().([]any); ok { + vals := make([]string, 0, len(list)) + for _, v := range list { + if str, ok := v.(string); ok { + vals = append(vals, str) + } + } + s.uses[req.ServiceName] = vals + } + } + return &azdext.EmptyResponse{}, nil +} + +// newProjectRecorderClient spins up an in-process gRPC server backed by the +// supplied project server stub and returns a client wired to its address. +func newProjectRecorderClient(t *testing.T, server azdext.ProjectServiceServer) *azdext.AzdClient { + t.Helper() + + grpcServer := grpc.NewServer() + azdext.RegisterProjectServiceServer(grpcServer, server) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + serveErr := make(chan error, 1) + go func() { + if err := grpcServer.Serve(listener); err != nil { + serveErr <- err + } + }() + + t.Cleanup(func() { + grpcServer.Stop() + _ = listener.Close() + select { + case err := <-serveErr: + require.ErrorIs(t, err, grpc.ErrServerStopped) + default: + } + }) + + client, err := azdext.NewAzdClient(azdext.WithAddress(listener.Addr().String())) + require.NoError(t, err) + t.Cleanup(func() { client.Close() }) + + return client +} + +// TestEmitResourceServices_AlwaysEmitsProjectService verifies the ai-project +// service is written even when the agent has no deployments, connections, or +// toolboxes, and that the agent's uses: is wired to it. The project service is +// emitted unconditionally as the stable provisioning-order anchor every agent +// references rather than being gated on a Foundry resource being present. +func TestEmitResourceServices_AlwaysEmitsProjectService(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + + err := emitResourceServices(t.Context(), client, "myagent", nil, nil, nil) + require.NoError(t, err) + + server.mu.Lock() + defer server.mu.Unlock() + + require.Len(t, server.added, 1) + assert.Equal(t, aiProjectServiceName, server.added[0].Name) + assert.Equal(t, AiProjectHost, server.added[0].Host) + assert.Equal(t, []string{aiProjectServiceName}, server.uses["myagent"]) +} + +// TestEmitResourceServices_WiresSiblingsToProject verifies a connection service +// is emitted alongside the project service, depends on it via uses: so the +// project provisions first, and that the agent is wired to both siblings. +func TestEmitResourceServices_WiresSiblingsToProject(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + + conns := []project.Connection{{Name: "myconn", Category: "ApiKey"}} + err := emitResourceServices(t.Context(), client, "myagent", nil, conns, nil) + require.NoError(t, err) + + server.mu.Lock() + defer server.mu.Unlock() + + require.Len(t, server.added, 2) + assert.Equal(t, aiProjectServiceName, server.added[0].Name) + assert.Equal(t, AiProjectHost, server.added[0].Host) + assert.Equal(t, "myconn", server.added[1].Name) + assert.Equal(t, AiConnectionHost, server.added[1].Host) + + assert.Equal(t, []string{aiProjectServiceName}, server.uses["myconn"]) + assert.Equal(t, []string{aiProjectServiceName, "myconn"}, server.uses["myagent"]) +} From ffb505f8ae1f797b0a37299ea2ecb564d57372d2 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 12:04:26 +0800 Subject: [PATCH 13/50] feat: generalize $ref resolver for separate-services azure.yaml --- .../internal/project/includes.go | 21 ++- .../internal/project/includes_test.go | 158 ++++++++++++++++++ 2 files changed, 174 insertions(+), 5 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/includes.go b/cli/azd/extensions/azure.ai.agents/internal/project/includes.go index 6c67ff0c0f1..79c42a8885c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/includes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/includes.go @@ -29,13 +29,24 @@ const maxRefDepth = 32 // are rejected for now; only local YAML/JSON files are supported. var remoteRefPattern = regexp.MustCompile(`(?i)^[a-z][a-z0-9+.-]*://`) -// ResolveFileRefs resolves $ref file includes within a parsed Foundry service configuration. +// ResolveFileRefs resolves $ref file includes within a Foundry resource service configuration. // -// cfg is the Foundry service entry as already-parsed data (the inline azure.yaml keys that +// In the separate-services azure.yaml shape every Foundry resource is its own service entry, so +// each owning extension calls ResolveFileRefs on its resource's inline map: the entry keys that // reach the extension over gRPC, where they arrive as a structpb.Struct decoded to -// map[string]any). projectRoot is the directory that holds azure.yaml; relative $ref targets -// at the top level resolve against it, and rebased project/instructions paths are anchored to -// it. +// map[string]any. The core ServiceConfig fields (host, the service key, uses) are stripped by +// core and never appear here. cfg therefore takes any of these shapes: +// +// - A service-entry-level $ref. The $ref sits at the top level of the inline map, beside the +// host and service key that core already removed (e.g. an agent or skill entry whose body +// lives in ./agents/research-agent.yaml). The map itself is the $ref directive. +// - A deployment array-item $ref. Deployments stay an array on the project service, so each +// item in deployments may be its own $ref (e.g. ./deployments/gpt-4o.yaml). +// - Any nested $ref reached while walking the entry (a $ref inside a loaded file, or a sibling +// value), since resolution is recursive over every map and sequence node. +// +// projectRoot is the directory that holds azure.yaml; relative $ref targets at the top level +// resolve against it, and rebased project/instructions paths are anchored to it. // // Any object that contains a "$ref" string is replaced by the referenced YAML or JSON file, // with the object's remaining keys overlaid on top. The overlay is a shallow, top-level merge: diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/includes_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/includes_test.go index 271b168c439..e58add38b61 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/includes_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/includes_test.go @@ -386,3 +386,161 @@ agents: _, err := ResolveFileRefs(cfg, root) requireFileRefError(t, err, "must not be empty") } + +// In the separate-services shape an agent's body lives in its own file and the inline map is +// itself the $ref directive (the host and service key are stripped by core before the entry +// reaches the extension), so the whole entry resolves from the file. +func TestResolveFileRefs_ServiceEntryTopLevelRef(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "agents/research-agent.yaml", ` +kind: hosted +project: ../src/research-agent +docker: + path: Dockerfile +`) + cfg := parseYAML(t, ` +$ref: ./agents/research-agent.yaml +`) + + got, err := ResolveFileRefs(cfg, root) + require.NoError(t, err) + + // project rebased from the agents/ directory to the project root; docker.path is not a + // path-bearing key and is left untouched. + want := parseYAML(t, ` +kind: hosted +project: src/research-agent +docker: + path: Dockerfile +`) + assert.Equal(t, want, got) +} + +// A service-entry-level $ref may carry sibling overrides authored inline in azure.yaml, which +// overlay the loaded file shallowly. Inline values are left exactly as written; only the file's +// own paths rebase. +func TestResolveFileRefs_ServiceEntryTopLevelRefWithOverlay(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "agents/research-agent.yaml", ` +kind: hosted +project: ../src/research-agent +env: + A: "1" +`) + cfg := parseYAML(t, ` +$ref: ./agents/research-agent.yaml +kind: prompt +instructions: Do the thing inline. +env: + B: "2" +`) + + got, err := ResolveFileRefs(cfg, root) + require.NoError(t, err) + + // kind scalar overridden, env map replaced wholesale (shallow), inline instructions prose + // kept as-is (not a .md/.txt path, so not rebased), and the file's project still rebases. + want := parseYAML(t, ` +kind: prompt +project: src/research-agent +instructions: Do the thing inline. +env: + B: "2" +`) + assert.Equal(t, want, got) +} + +// Deployments stay an array on the project service, so a deployment $ref sits at the array-item +// level. Each item resolves independently; inline items pass through unchanged. +func TestResolveFileRefs_ProjectDeploymentsArrayItemRef(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "deployments/gpt-4o.yaml", ` +name: gpt-4o +model: + name: gpt-4o + format: OpenAI + version: "2024-08-06" +sku: + name: GlobalStandard + capacity: 10 +`) + cfg := parseYAML(t, ` +endpoint: https://my-account.services.ai.azure.com/api/projects/my-project +deployments: + - $ref: ./deployments/gpt-4o.yaml + - name: text-embedding-3-large + model: + name: text-embedding-3-large + format: OpenAI + version: "1" + sku: + name: Standard + capacity: 50 +`) + + got, err := ResolveFileRefs(cfg, root) + require.NoError(t, err) + + want := parseYAML(t, ` +endpoint: https://my-account.services.ai.azure.com/api/projects/my-project +deployments: + - name: gpt-4o + model: + name: gpt-4o + format: OpenAI + version: "2024-08-06" + sku: + name: GlobalStandard + capacity: 10 + - name: text-embedding-3-large + model: + name: text-embedding-3-large + format: OpenAI + version: "1" + sku: + name: Standard + capacity: 50 +`) + assert.Equal(t, want, got) +} + +// A deployment array-item $ref may carry sibling overrides, which overlay the loaded file +// shallowly: scalars replace, and a sibling map replaces the loaded map wholesale. +func TestResolveFileRefs_DeploymentRefOverlay(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "deployments/base.yaml", ` +name: base-name +model: + name: gpt-4o + format: OpenAI + version: "2024-08-06" +sku: + name: Standard + capacity: 10 +`) + cfg := parseYAML(t, ` +deployments: + - $ref: ./deployments/base.yaml + name: overridden + sku: + name: GlobalStandard + capacity: 100 +`) + + got, err := ResolveFileRefs(cfg, root) + require.NoError(t, err) + + // name scalar overridden, sku map replaced wholesale, model left untouched. + want := parseYAML(t, ` +deployments: + - name: overridden + model: + name: gpt-4o + format: OpenAI + version: "2024-08-06" + sku: + name: GlobalStandard + capacity: 100 +`) + assert.Equal(t, want, got) +} From 89a5d3a7cc26315797f9de8fed99775e3de803c2 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 12:04:34 +0800 Subject: [PATCH 14/50] feat: add $ref-aware azure.yaml edit helper for Foundry services --- .../internal/project/includes_edit.go | 342 ++++++++++++++++++ .../internal/project/includes_edit_test.go | 265 ++++++++++++++ 2 files changed, 607 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/includes_edit.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit.go b/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit.go new file mode 100644 index 00000000000..f1695fdba94 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit.go @@ -0,0 +1,342 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "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 +// requested. It mirrors yamlnode.ErrNodeNotFound so callers can branch on a sentinel. +var ErrServiceNotFound = errors.New("service entry not found") + +// EditTarget selects where a write to a service entry lands. +type EditTarget int + +const ( + // EditInline writes the field on the service entry in the holding document, beside any + // $ref. This is the spec §2.4 default (append inline): ResolveFileRefs reads such a key as + // an overlay override layered on top of the referenced file, so the write is read back as + // the winning value. + EditInline EditTarget = iota + + // EditRefFile follows the entry's $ref and writes the field into the referenced split file + // instead. If the entry is not $ref-backed it falls back to an inline write. + EditRefFile +) + +// YAMLDocument is an editable, comment-preserving azure.yaml (or a referenced split file). It +// wraps a braydonk yaml.Node tree so edits keep comments, key order, and block-scalar style, +// matching how azd core round-trips azure.yaml. +// +// It is the $ref-aware write counterpart to ResolveFileRefs: EntryRef recognizes a $ref entry +// with the same key the resolver uses, and EditRefFile resolves the split-file path with the +// resolver's shared path logic, so reads and writes of $ref entries agree. The composition +// commands tracked by #8049 share this helper. +// +// Edits mutate the tree in memory; call Save to persist. Writes through EditRefFile lazily load +// the referenced file, and Save persists every file touched this way alongside the main one. +type YAMLDocument struct { + path string + root yaml.Node + refDocs map[string]*YAMLDocument +} + +// LoadYAMLDocument reads and parses the YAML file at path into an editable document. +func LoadYAMLDocument(path string) (*YAMLDocument, error) { + // #nosec G304 -- azure.yaml and its $ref split files are trusted config input, the same + // 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, + fmt.Sprintf("cannot read YAML file %q: %v", path, err), + "Check that the path is correct and the file exists and is readable.", + ) + } + return ParseYAMLDocument(path, data) +} + +// ParseYAMLDocument parses data as the YAML document located (logically) at path. path is used +// to resolve relative $ref split-file targets and as the destination for Save; it need not +// exist on disk yet. Empty input yields an empty document whose root is created on first edit. +func ParseYAMLDocument(path string, data []byte) (*YAMLDocument, error) { + doc := &YAMLDocument{path: path, refDocs: map[string]*YAMLDocument{}} + if len(bytes.TrimSpace(data)) == 0 { + return doc, nil + } + + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.SetScanBlockScalarAsLiteral(true) + if err := decoder.Decode(&doc.root); err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidFileRef, + fmt.Sprintf("YAML file %q is not valid: %v", path, err), + "Fix the file so it parses as a YAML document.", + ) + } + return doc, nil +} + +// Bytes serializes the document, preserving comments, key order, and block-scalar style with +// two-space indentation (the azure.yaml convention used by azd core). +func (d *YAMLDocument) Bytes() ([]byte, error) { + if d.root.Kind == 0 { + return []byte{}, nil + } + + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + // preserve multi-line block scalar style + encoder.SetAssumeBlockAsLiteral(true) + if err := encoder.Encode(&d.root); err != nil { + _ = encoder.Close() + return nil, fmt.Errorf("encoding YAML: %w", err) + } + if err := encoder.Close(); err != nil { + return nil, fmt.Errorf("closing YAML encoder: %w", err) + } + return buf.Bytes(), nil +} + +// Save writes the document back to its path, then persists every referenced split file edited +// through EditRefFile. +func (d *YAMLDocument) Save() error { + data, err := d.Bytes() + if err != nil { + return err + } + if err := os.WriteFile(d.path, data, osutil.PermissionFile); err != nil { + return fmt.Errorf("writing %q: %w", d.path, err) + } + for _, sub := range d.refDocs { + if err := sub.Save(); err != nil { + return err + } + } + return nil +} + +// ServiceEntry returns the mapping node for services.. When create is true a missing +// services mapping and/or entry are added (and returned); when false a missing entry returns +// ErrServiceNotFound. +func (d *YAMLDocument) ServiceEntry(name string, create bool) (*yaml.Node, error) { + if name == "" { + return nil, errors.New("service name must not be empty") + } + + services, err := d.servicesNode(create) + if err != nil { + return nil, err + } + if services != nil { + if entry := mappingValue(services, name); entry != nil { + if entry.Kind != yaml.MappingNode { + return nil, fmt.Errorf("%w: service %q is not a mapping", yamlnode.ErrNodeWrongKind, name) + } + return entry, nil + } + } + + if !create { + return nil, fmt.Errorf("%w: %s", ErrServiceNotFound, name) + } + + entry := &yaml.Node{Kind: yaml.MappingNode} + services.Content = append(services.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: name}, + entry, + ) + return entry, nil +} + +// SetServiceField sets services.. to value, creating the service entry if missing. +// It is $ref-aware: +// +// - EditInline writes the key on the entry node beside any $ref. ResolveFileRefs reads it as +// an overlay override on top of the referenced file. +// - EditRefFile writes the key into the file named by the entry's $ref instead (resolved +// relative to this document with the resolver's shared path logic). If the entry has no +// $ref it falls back to an inline write. +// +// value may be any value yamlnode.Encode accepts (scalars, sequences, mappings). +func (d *YAMLDocument) SetServiceField(name, key string, value any, target EditTarget) error { + if key == "" { + return errors.New("field key must not be empty") + } + + entry, err := d.ServiceEntry(name, true) + if err != nil { + return err + } + + valueNode, err := yamlnode.Encode(value) + if err != nil { + return err + } + + if target == EditRefFile { + if ref, isRef := EntryRef(entry); isRef { + return d.setRefFileField(ref, key, valueNode) + } + // No split file to target; fall back to an inline write. + } + + return setEntryField(entry, key, valueNode) +} + +// EntryRef reports the $ref target of a service entry and whether the entry is $ref-backed. It +// recognizes the same directive key as ResolveFileRefs, so a writer and the resolver agree on +// which entries are file includes. +func EntryRef(entry *yaml.Node) (string, bool) { + value := mappingValue(entry, refKey) + if value == nil || value.Kind != yaml.ScalarNode { + return "", false + } + ref := strings.TrimSpace(value.Value) + if ref == "" { + return "", false + } + return ref, true +} + +// setRefFileField loads (and caches) the split file named by ref and sets key on its root +// mapping. The file is persisted by Save. +func (d *YAMLDocument) setRefFileField(ref, key string, valueNode *yaml.Node) error { + target, err := refTargetPath(ref, d.dir()) + if err != nil { + return err + } + + sub, err := d.refDoc(target) + if err != nil { + return err + } + + subRoot, err := sub.rootMapping(true) + if err != nil { + return err + } + return setEntryField(subRoot, key, valueNode) +} + +// refDoc returns the cached editable document for the split file at target, loading it once. +func (d *YAMLDocument) refDoc(target string) (*YAMLDocument, error) { + if sub, ok := d.refDocs[target]; ok { + return sub, nil + } + sub, err := LoadYAMLDocument(target) + if err != nil { + return nil, err + } + d.refDocs[target] = sub + return sub, nil +} + +// dir returns the directory that holds the document, used to resolve relative $ref targets. +func (d *YAMLDocument) dir() string { + if d.path == "" { + return "." + } + return filepath.Dir(d.path) +} + +// servicesNode returns the top-level services mapping, optionally creating it. +func (d *YAMLDocument) servicesNode(create bool) (*yaml.Node, error) { + root, err := d.rootMapping(create) + if err != nil || root == nil { + return nil, err + } + + if services := mappingValue(root, "services"); services != nil { + if services.Kind != yaml.MappingNode { + return nil, fmt.Errorf("%w: services is not a mapping", yamlnode.ErrNodeWrongKind) + } + return services, nil + } + + if !create { + return nil, nil + } + + services := &yaml.Node{Kind: yaml.MappingNode} + root.Content = append(root.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "services"}, + services, + ) + return services, nil +} + +// rootMapping returns the document's root mapping node, optionally initializing an empty +// document and root mapping. +func (d *YAMLDocument) rootMapping(create bool) (*yaml.Node, error) { + if d.root.Kind == 0 { + if !create { + return nil, nil + } + d.root = yaml.Node{Kind: yaml.DocumentNode} + } + if d.root.Kind != yaml.DocumentNode { + return nil, fmt.Errorf("%w: YAML root is not a document", yamlnode.ErrNodeWrongKind) + } + + if len(d.root.Content) == 0 { + if !create { + return nil, nil + } + d.root.Content = []*yaml.Node{{Kind: yaml.MappingNode}} + } + + root := d.root.Content[0] + if root.Kind != yaml.MappingNode { + return nil, fmt.Errorf("%w: YAML root is not a mapping", yamlnode.ErrNodeWrongKind) + } + return root, nil +} + +// mappingValue returns the value node for key in a mapping node, or nil when absent. +func mappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + return nil +} + +// setEntryField sets key on the mapping node via yamlnode.Set, first transferring the comments +// of any replaced value onto the new node so an inline annotation survives an in-place update. +// A new key is appended (preserving existing key order). +func setEntryField(entry *yaml.Node, key string, valueNode *yaml.Node) error { + if old := mappingValue(entry, key); old != nil { + valueNode.HeadComment = old.HeadComment + valueNode.LineComment = old.LineComment + valueNode.FootComment = old.FootComment + } + return yamlnode.Set(entry, quotePathSegment(key), valueNode) +} + +// quotePathSegment escapes a single yamlnode dotted-path segment so a key containing the path +// syntax's special characters (. [ ] ? ") is treated literally. +func quotePathSegment(segment string) string { + if !strings.ContainsAny(segment, `.[]?"`) { + return segment + } + return `"` + strings.ReplaceAll(segment, `"`, `\"`) + `"` +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go new file mode 100644 index 00000000000..0820ce6c5c6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go @@ -0,0 +1,265 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// readFile reads dir-relative or absolute path content as a string. +func readFile(t *testing.T, path string) string { + t.Helper() + // #nosec G304 -- tests read files they just wrote under a t.TempDir() root. + data, err := os.ReadFile(path) + require.NoError(t, err) + return string(data) +} + +// serviceEntryMap parses an azure.yaml document and returns services. as a map, matching +// the already-parsed inline map a provider would feed to ResolveFileRefs. +func serviceEntryMap(t *testing.T, doc, name string) map[string]any { + t.Helper() + parsed := parseYAML(t, doc) + services, ok := parsed["services"].(map[string]any) + require.True(t, ok, "services mapping missing") + entry, ok := services[name].(map[string]any) + require.True(t, ok, "service %q missing", name) + return entry +} + +func TestYAMLDocument_RoundTripPreservesCommentsAndOrder(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "azure.yaml") + src := `name: my-project # inline project name +# a standalone comment about services +services: + research-agent: + # the agent host + host: azure.ai.agent + $ref: ./agents/research-agent.yaml +` + require.NoError(t, os.WriteFile(path, []byte(src), 0o600)) + + doc, err := LoadYAMLDocument(path) + require.NoError(t, err) + out, err := doc.Bytes() + require.NoError(t, err) + first := string(out) + + // Comments survive the round trip. + assert.Contains(t, first, "# inline project name") + assert.Contains(t, first, "# a standalone comment about services") + assert.Contains(t, first, "# the agent host") + // Key order within the entry is preserved (host before $ref). + assert.Less(t, strings.Index(first, "host:"), strings.Index(first, "$ref:")) + + // Re-encoding is stable (idempotent). + doc2, err := ParseYAMLDocument(path, out) + require.NoError(t, err) + out2, err := doc2.Bytes() + require.NoError(t, err) + assert.Equal(t, first, string(out2)) +} + +func TestYAMLDocument_ServiceEntry_FindMissingCreate(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "azure.yaml") + src := `name: p +services: + existing: + host: azure.ai.agent +` + require.NoError(t, os.WriteFile(path, []byte(src), 0o600)) + doc, err := LoadYAMLDocument(path) + require.NoError(t, err) + + entry, err := doc.ServiceEntry("existing", false) + require.NoError(t, err) + require.NotNil(t, entry) + assert.Equal(t, "azure.ai.agent", mappingValue(entry, "host").Value) + + _, err = doc.ServiceEntry("missing", false) + require.ErrorIs(t, err, ErrServiceNotFound) + + created, err := doc.ServiceEntry("missing", true) + require.NoError(t, err) + require.NotNil(t, created) + + // The created entry is now findable and is the same node. + again, err := doc.ServiceEntry("missing", false) + require.NoError(t, err) + assert.Same(t, created, again) +} + +func TestYAMLDocument_ServiceEntry_CreatesServicesMapping(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "azure.yaml") + require.NoError(t, os.WriteFile(path, []byte("name: p\n"), 0o600)) + + doc, err := LoadYAMLDocument(path) + require.NoError(t, err) + entry, err := doc.ServiceEntry("svc", true) + require.NoError(t, err) + require.NotNil(t, entry) + require.NoError(t, doc.Save()) + + saved := readFile(t, path) + assert.Contains(t, saved, "services:") + assert.Contains(t, saved, "svc:") +} + +func TestEntryRef(t *testing.T) { + doc, err := ParseYAMLDocument("azure.yaml", []byte(` +services: + ref-svc: + host: azure.ai.agent + $ref: ./agents/a.yaml + inline-svc: + host: azure.ai.agent + kind: prompt +`)) + require.NoError(t, err) + + refEntry, err := doc.ServiceEntry("ref-svc", false) + require.NoError(t, err) + target, isRef := EntryRef(refEntry) + assert.True(t, isRef) + assert.Equal(t, "./agents/a.yaml", target) + + inlineEntry, err := doc.ServiceEntry("inline-svc", false) + require.NoError(t, err) + _, isRef = EntryRef(inlineEntry) + assert.False(t, isRef) +} + +func TestYAMLDocument_SetServiceField_InlineOverlayAndAgreement(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "agents/research-agent.yaml", ` +kind: hosted +project: ../src/research-agent +`) + path := filepath.Join(root, "azure.yaml") + src := `services: + research-agent: + host: azure.ai.agent + $ref: ./agents/research-agent.yaml +` + require.NoError(t, os.WriteFile(path, []byte(src), 0o600)) + + doc, err := LoadYAMLDocument(path) + require.NoError(t, err) + require.NoError(t, doc.SetServiceField("research-agent", "kind", "prompt", EditInline)) + require.NoError(t, doc.Save()) + + saved := readFile(t, path) + // $ref is preserved and the overlay key is added beside it. + assert.Contains(t, saved, "$ref: ./agents/research-agent.yaml") + assert.Contains(t, saved, "kind: prompt") + + // Reads and writes agree: the resolver overlays the inline key over the loaded file, so the + // inline value wins while the file's other (rebased) fields come through. + resolved, err := ResolveFileRefs(serviceEntryMap(t, saved, "research-agent"), root) + require.NoError(t, err) + assert.Equal(t, "prompt", resolved["kind"]) + assert.Equal(t, "src/research-agent", resolved["project"]) +} + +func TestYAMLDocument_SetServiceField_InlineUpdateInPlace(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "azure.yaml") + src := `services: + agent-project: + host: azure.ai.project + endpoint: https://old.example.com # current endpoint +` + require.NoError(t, os.WriteFile(path, []byte(src), 0o600)) + + doc, err := LoadYAMLDocument(path) + require.NoError(t, err) + require.NoError(t, doc.SetServiceField("agent-project", "endpoint", "https://new.example.com", EditInline)) + require.NoError(t, doc.Save()) + + saved := readFile(t, path) + assert.Contains(t, saved, "https://new.example.com") + assert.NotContains(t, saved, "https://old.example.com") + // The replaced value's inline comment is preserved. + assert.Contains(t, saved, "# current endpoint") + // Existing key order is preserved (host before endpoint). + assert.Less(t, strings.Index(saved, "host:"), strings.Index(saved, "endpoint:")) +} + +func TestYAMLDocument_SetServiceField_EditRefFile(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "agents/research-agent.yaml", ` +kind: hosted +project: ../src/research-agent +`) + path := filepath.Join(root, "azure.yaml") + src := `services: + research-agent: + host: azure.ai.agent + $ref: ./agents/research-agent.yaml +` + require.NoError(t, os.WriteFile(path, []byte(src), 0o600)) + + doc, err := LoadYAMLDocument(path) + require.NoError(t, err) + require.NoError(t, doc.SetServiceField("research-agent", "kind", "prompt", EditRefFile)) + require.NoError(t, doc.Save()) + + // The azure.yaml entry is untouched: still just host + $ref. + savedMain := readFile(t, path) + assert.Contains(t, savedMain, "$ref: ./agents/research-agent.yaml") + assert.NotContains(t, savedMain, "kind:") + + // The split file received the new value. + savedRef := readFile(t, filepath.Join(root, "agents", "research-agent.yaml")) + assert.Contains(t, savedRef, "kind: prompt") + assert.NotContains(t, savedRef, "hosted") + + // The resolver now reads the new value from the file. + resolved, err := ResolveFileRefs(serviceEntryMap(t, savedMain, "research-agent"), root) + require.NoError(t, err) + assert.Equal(t, "prompt", resolved["kind"]) +} + +func TestYAMLDocument_SetServiceField_EditRefFileFallsBackInline(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "azure.yaml") + src := `services: + agent-project: + host: azure.ai.project +` + require.NoError(t, os.WriteFile(path, []byte(src), 0o600)) + + doc, err := LoadYAMLDocument(path) + require.NoError(t, err) + // The entry has no $ref, so EditRefFile falls back to an inline write. + require.NoError(t, doc.SetServiceField("agent-project", "endpoint", "https://e.example.com", EditRefFile)) + require.NoError(t, doc.Save()) + + assert.Contains(t, readFile(t, path), "endpoint: https://e.example.com") +} + +func TestYAMLDocument_SetServiceField_EditRefFileMissing(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "azure.yaml") + src := `services: + research-agent: + host: azure.ai.agent + $ref: ./agents/missing.yaml +` + require.NoError(t, os.WriteFile(path, []byte(src), 0o600)) + + doc, err := LoadYAMLDocument(path) + require.NoError(t, err) + err = doc.SetServiceField("research-agent", "kind", "prompt", EditRefFile) + requireFileRefError(t, err, "cannot read") +} From 6399807a0518128a95a5de7024c07590d2a21ca0 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 12:50:21 +0800 Subject: [PATCH 15/50] test: add EntryRef edge-case tests for empty/nil/numeric \ --- .../internal/project/includes_edit_test.go | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go index 0820ce6c5c6..44744d0c2db 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go @@ -139,6 +139,58 @@ services: assert.False(t, isRef) } +// TestEntryRef_EdgeCases documents the intentional divergence from the resolver for +// degenerate $ref values: EntryRef falls back to (false) rather than erroring, so the +// write path treats the entry as inline. The read path (ResolveFileRefs) will surface +// CodeInvalidFileRef on these same inputs — the divergence is safe because the write +// still lands somewhere, and the read failure gives a clear diagnostic. +func TestEntryRef_EdgeCases(t *testing.T) { + tests := []struct { + name string + yaml string + wantRef bool + wantVal string + }{ + { + name: "empty $ref falls back to not-a-ref", + yaml: "services:\n svc:\n $ref: \"\"\n", + }, + { + name: "whitespace-only $ref falls back to not-a-ref", + yaml: "services:\n svc:\n $ref: \" \"\n", + }, + { + name: "no $ref key at all", + yaml: "services:\n svc:\n kind: prompt\n", + }, + { + name: "numeric $ref value is coerced to string — treated as ref", + yaml: "services:\n svc:\n $ref: 123\n", + wantRef: true, + wantVal: "123", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + doc, err := ParseYAMLDocument("azure.yaml", []byte(tc.yaml)) + require.NoError(t, err) + entry, err := doc.ServiceEntry("svc", false) + require.NoError(t, err) + got, isRef := EntryRef(entry) + assert.Equal(t, tc.wantRef, isRef) + if tc.wantRef { + assert.Equal(t, tc.wantVal, got) + } + }) + } +} + +// TestEntryRef_NilEntry documents that EntryRef is nil-safe. +func TestEntryRef_NilEntry(t *testing.T) { + _, isRef := EntryRef(nil) + assert.False(t, isRef) +} + func TestYAMLDocument_SetServiceField_InlineOverlayAndAgreement(t *testing.T) { root := t.TempDir() writeFile(t, root, "agents/research-agent.yaml", ` From 8529134339f3c7315a5a9bbf141a4d2d99da5884 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 13:03:54 +0800 Subject: [PATCH 16/50] test: guard substring order assertions --- .../internal/project/includes_edit_test.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go index 44744d0c2db..f85352d95c4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go @@ -34,6 +34,15 @@ func serviceEntryMap(t *testing.T, doc, name string) map[string]any { return entry } +func assertSubstringOrder(t *testing.T, s, before, after string) { + t.Helper() + beforeIndex := strings.Index(s, before) + afterIndex := strings.Index(s, after) + require.NotEqual(t, -1, beforeIndex, "%q missing", before) + require.NotEqual(t, -1, afterIndex, "%q missing", after) + assert.Less(t, beforeIndex, afterIndex) +} + func TestYAMLDocument_RoundTripPreservesCommentsAndOrder(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "azure.yaml") @@ -58,7 +67,7 @@ services: assert.Contains(t, first, "# a standalone comment about services") assert.Contains(t, first, "# the agent host") // Key order within the entry is preserved (host before $ref). - assert.Less(t, strings.Index(first, "host:"), strings.Index(first, "$ref:")) + assertSubstringOrder(t, first, "host:", "$ref:") // Re-encoding is stable (idempotent). doc2, err := ParseYAMLDocument(path, out) @@ -244,7 +253,7 @@ func TestYAMLDocument_SetServiceField_InlineUpdateInPlace(t *testing.T) { // The replaced value's inline comment is preserved. assert.Contains(t, saved, "# current endpoint") // Existing key order is preserved (host before endpoint). - assert.Less(t, strings.Index(saved, "host:"), strings.Index(saved, "endpoint:")) + assertSubstringOrder(t, saved, "host:", "endpoint:") } func TestYAMLDocument_SetServiceField_EditRefFile(t *testing.T) { From 66c22a8d61c60e55feb73ead1f3dd381059711c3 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 13:46:35 +0800 Subject: [PATCH 17/50] feat: carry foundry agent definition in azure.yaml --- .../internal/cmd/endpoint_show.go | 21 +- .../azure.ai.agents/internal/cmd/helpers.go | 70 ++- .../azure.ai.agents/internal/cmd/init.go | 67 ++- .../internal/cmd/init_from_code.go | 99 ++--- .../internal/cmd/init_from_code_reuse.go | 2 +- .../azure.ai.agents/internal/cmd/listen.go | 128 ++---- .../internal/cmd/resource_services.go | 9 +- .../internal/project/agent_definition.go | 411 ++++++++++++++++++ .../internal/project/service_target_agent.go | 167 +++---- .../schemas/microsoft.foundry.json | 44 -- cli/azd/internal/tracing/fields/fields.go | 9 + cli/azd/pkg/project/project.go | 10 + schemas/alpha/azure.yaml.json | 36 +- schemas/v1.0/azure.yaml.json | 36 +- 14 files changed, 659 insertions(+), 450 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go delete mode 100644 cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json 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..c7a9579a304 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" ) @@ -727,11 +726,8 @@ 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 } return &ServiceRunContext{ @@ -797,7 +793,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 +805,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/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 8e18b21a14c..384b0a6c1cd 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" @@ -1652,9 +1651,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 @@ -2774,29 +2775,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 { @@ -2946,17 +2929,31 @@ 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", + 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..f8425bad29b 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,28 @@ 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, + 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/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 83f2372a949..474d28aa729 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -6,9 +6,7 @@ package cmd import ( "context" "encoding/json" - "errors" "fmt" - "io/fs" "log" "net/url" "os" @@ -16,17 +14,13 @@ import ( "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 @@ -231,23 +225,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 +415,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 +474,28 @@ 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) - 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) - return nil - } - return fmt.Errorf("failed to read YAML file: %w", err) - } - - err = agent_yaml.ValidateAgentDefinition(data) +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 { - return fmt.Errorf("agent.yaml is not valid: %w", err) + log.Printf("[debug] kindEnvUpdate: skipping %s, no readable agent definition: %v", svc.Name, err) + return nil } - - var genericTemplate map[string]any - if err := yaml.Unmarshal(data, &genericTemplate); err != nil { - return fmt.Errorf("YAML content is not valid: %w", err) + if source.IsLegacy() { + project.WarnLegacyAgentShape(source) } - kind, ok := genericTemplate["kind"].(string) - if !ok { - return fmt.Errorf("kind field is not a valid string") - } - - 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 +643,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} 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..2dcf87d8647 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 @@ -330,11 +330,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/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go new file mode 100644 index 00000000000..e6ff6f920b6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -0,0 +1,411 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "fmt" + "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/issues/8773" + +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.manifest.yaml" + } + 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 the CPU/memory Resources, +// which are carried in the `container` config to avoid a key/type collision with +// the tool Resources list ([ServiceTargetAgentConfig.Resources], also keyed +// `resources`). 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"` + Image string `json:"image,omitempty"` + 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 Resources are returned separately so the caller can store them +// in the `container` config. +func agentDefinitionToInline(ca agent_yaml.ContainerAgent) (AgentDefinitionInline, *ContainerSettings) { + inline := AgentDefinitionInline{ + AgentDefinition: ca.AgentDefinition, + Image: ca.Image, + 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 +} + +// toContainerAgent rebuilds the agent_yaml.ContainerAgent from the inline +// definition plus the CPU/memory carried in the `container` config. +func (d AgentDefinitionInline) toContainerAgent(container *ContainerSettings) agent_yaml.ContainerAgent { + ca := agent_yaml.ContainerAgent{ + AgentDefinition: d.AgentDefinition, + Image: d.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) + 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() +} + +// 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. +func agentDefinitionFromStruct(s *structpb.Struct) (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) + 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 { + continue + } + 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. +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/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index bd7fb81865b..f436a05e77f 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 { return nil } if p.serviceConfig == nil { @@ -270,6 +272,9 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er 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 != "" { // Verify the file exists and has correct extension @@ -293,10 +298,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 } - // Look for agent.yaml or agent.yml in the service directory root + // 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 + } + + // 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 +332,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,6 +961,17 @@ func hasContainerArtifact(artifacts []*azdext.Artifact) bool { } func (p *AgentServiceTargetProvider) loadContainerAgentDefinition() (agent_yaml.ContainerAgent, bool, error) { + // Prefer the agent definition carried inline on the service entry (the + // unified service-level shape, or the deprecated config-nested shape). Fall + // back to a legacy agent.yaml/agent.yml on disk so older projects still build + // and deploy during the deprecation window. + if ca, isHosted, found, source, err := AgentDefinitionFromService(p.serviceConfig); found || err != nil { + if found && source.IsLegacy() { + WarnLegacyAgentShape(source) + } + return ca, isHosted, err + } + data, err := os.ReadFile(p.agentDefinitionPath) if err != nil { return agent_yaml.ContainerAgent{}, false, exterrors.Validation( @@ -953,54 +981,8 @@ func (p *AgentServiceTargetProvider) loadContainerAgentDefinition() (agent_yaml. ) } - 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 + WarnLegacyAgentShape(AgentDefinitionSourceDisk) + return parseContainerAgentYAML(data) } // Deploy performs the deployment operation for the agent service @@ -1036,8 +1018,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 +1031,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 +1145,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 { + agentDef, isHosted, err := p.loadContainerAgentDefinition() + if err != nil || !isHosted { 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) { - return false - } - - _, hasCodeConfig := genericTemplate["code_configuration"] - return hasCodeConfig + return agentDef.CodeConfiguration != nil } // deployPrepResult holds the common outputs from prepareDeploy, used by both @@ -1236,7 +1203,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 +1218,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 +1227,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 +1439,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/schemas/microsoft.foundry.json b/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json deleted file mode 100644 index 34a5d502a2c..00000000000 --- a/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "$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/microsoft.foundry.json", - "title": "Microsoft Foundry project (services entry with host: microsoft.foundry)", - "description": "Schema for a Foundry project as a service in azure.yaml. Composes per-resource sub-schemas via $ref, modeled on microsoft/AgentSchema's split-file pattern.", - "type": "object", - "additionalProperties": true, - "properties": { - "endpoint": { - "type": "string", - "description": "URL of an existing Foundry project. When present, azd connects to the existing project instead of provisioning a new one. Absence of this field is the signal to provision a new project." - }, - "deployments": { - "type": "array", - "description": "Model deployments to create on the Foundry project.", - "items": { "$ref": "Deployment.json" } - }, - "connections": { - "type": "array", - "description": "Project connections.", - "items": { "$ref": "Connection.json" } - }, - "toolboxes": { - "type": "array", - "description": "Named toolboxes that agents can reference by name.", - "items": { "$ref": "Toolbox.json" } - }, - "skills": { - "type": "array", - "description": "Named skills that agents can reference by name.", - "items": { "$ref": "Skill.json" } - }, - "routines": { - "type": "array", - "description": "Scheduled or event-driven agent invocations.", - "items": { "$ref": "Routine.json" } - }, - "agents": { - "type": "array", - "description": "All agent definitions (hosted and prompt).", - "items": { "$ref": "Agent.json" } - } - } -} 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/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/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index cc204c3eef0..1df6f4bb2ab 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -230,8 +230,7 @@ "staticwebapp", "aks", "ai.endpoint", - "azure.ai.agent", - "microsoft.foundry" + "azure.ai.agent" ] }, "language": { @@ -411,7 +410,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,36 +418,13 @@ }, "then": { "required": ["project"], - "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, - "k8s": false, - "apiVersion": false, - "env": false - } - } - }, - { - "comment": "Microsoft Foundry host - project config composed at the service level", - "if": { - "properties": { - "host": { "const": "microsoft.foundry" } - } - }, - "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/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json" } ], "properties": { - "project": false, - "runtime": false, - "docker": false, - "image": false, - "config": false + "config": false, + "k8s": false, + "apiVersion": false } } }, diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 03961e907e7..838af5417bd 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -191,8 +191,7 @@ "staticwebapp", "aks", "ai.endpoint", - "azure.ai.agent", - "microsoft.foundry" + "azure.ai.agent" ] }, "language": { @@ -371,7 +370,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,36 +378,13 @@ }, "then": { "required": ["project"], - "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, - "k8s": false, - "apiVersion": false, - "env": false - } - } - }, - { - "comment": "Microsoft Foundry host - project config composed at the service level", - "if": { - "properties": { - "host": { "const": "microsoft.foundry" } - } - }, - "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/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json" } ], "properties": { - "project": false, - "runtime": false, - "docker": false, - "image": false, - "config": false + "config": false, + "k8s": false, + "apiVersion": false } } }, From 79bcd971296cc2f2ffe56bc6053205900793e70f Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 13:56:01 +0800 Subject: [PATCH 18/50] feat: wire foundry commands to inline agent definition --- .../azure.ai.agents/internal/cmd/helpers.go | 12 +++++ .../internal/cmd/optimize_apply.go | 35 +++++++++---- .../azure.ai.agents/internal/cmd/run.go | 27 ++-------- .../azure.ai.agents/internal/cmd/update.go | 22 +++----- .../internal/project/agent_definition.go | 52 +++++++++++++++++++ 5 files changed, 103 insertions(+), 45 deletions(-) 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 c7a9579a304..5e49a58a7d7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go @@ -706,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 @@ -730,10 +733,19 @@ func resolveServiceRunContext(ctx context.Context, azdClient *azdext.AzdClient, 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) + } + } + return &ServiceRunContext{ ServiceName: svc.Name, ProjectDir: projectDir, StartupCommand: startupCmd, + Definition: definition, }, nil } 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..3b61bda9aef 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,7 @@ import ( "azureaiagent/internal/pkg/agents/opt_eval" "azureaiagent/internal/pkg/agents/optimize_api" + projectpkg "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/fatih/color" @@ -160,15 +161,31 @@ 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, _, _ := projectpkg.AgentDefinitionFromService(svc); 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/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/update.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/update.go index 95f3c4a5007..beb78e76c13 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 { @@ -91,25 +90,20 @@ 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 agent_endpoint or agent_card — nothing to update", svc.Name, ) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index e6ff6f920b6..8c0755918ac 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -222,6 +222,58 @@ func ServiceConfigProps(svc *azdext.ServiceConfig) *structpb.Struct { 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 From d66bb15e3d4dd11919a3e56feb03927975fd5aca Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 14:15:57 +0800 Subject: [PATCH 19/50] test: cover inline agent definition and migration fallback --- .../internal/cmd/helpers_test.go | 29 +--- .../internal/cmd/init_from_code_test.go | 103 ++--------- .../azure.ai.agents/internal/cmd/listen.go | 13 +- .../azure.ai.agents/internal/cmd/run_test.go | 85 +++------ .../internal/exterrors/codes.go | 5 + .../internal/project/agent_definition.go | 6 +- .../internal/project/agent_definition_test.go | 162 ++++++++++++++++++ .../internal/project/service_target_agent.go | 2 +- 8 files changed, 231 insertions(+), 174 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go 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_from_code_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go index 9384ea2623e..b6282a7c5ca 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) + if _, err := os.Stat(filepath.Join(srcDir, ".agentignore")); err != nil { + t.Fatalf("expected .agentignore to exist: %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) - } - // 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) } }) } 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 474d28aa729..fbe7a85a2f1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -6,6 +6,7 @@ package cmd import ( "context" "encoding/json" + "errors" "fmt" "log" "net/url" @@ -488,8 +489,16 @@ func kindEnvUpdate( // targets that truly need the definition surface the error where they read it. _, isHosted, source, err := project.LoadAgentDefinition(svc, azdProject.Path) if err != nil { - log.Printf("[debug] kindEnvUpdate: skipping %s, no readable agent definition: %v", svc.Name, err) - return nil + // 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 err } if source.IsLegacy() { project.WarnLegacyAgentShape(source) 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/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index dc4b3123f35..f5d5bca90ff 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 index 8c0755918ac..da31a939476 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -355,7 +355,11 @@ func agentDefinitionFromDisk( for _, name := range []string{"agent.yaml", "agent.yml"} { defPath, err := paths.JoinAllowRoot(projectRoot, svc.GetRelativePath(), name) if err != nil { - continue + 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 { 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..6cf0ee32f1b --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -0,0 +1,162 @@ +// 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) +} + +// 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/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index f436a05e77f..21f78f51e92 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 @@ -191,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.deployContextReady { + if p.deployContextReady || p.agentDefinitionPath != "" { return nil } if p.serviceConfig == nil { From d83b553bb6326bfe71154af98fecaa6f0ea3863d Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 14:22:00 +0800 Subject: [PATCH 20/50] docs: document foundry legacy agent shape telemetry field --- docs/reference/telemetry-data.md | 1 + docs/specs/metrics-audit/telemetry-schema.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/reference/telemetry-data.md b/docs/reference/telemetry-data.md index 148b450a0cf..f64a0f8fe30 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/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 From 6f70bac27ce0154e5ca30d5fdd527ba9a6942ce4 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 14:24:57 +0800 Subject: [PATCH 21/50] test: drop unused containsAll helper --- .../internal/cmd/init_from_code_test.go | 10 ---------- 1 file changed, 10 deletions(-) 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 b6282a7c5ca..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 @@ -573,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() From 54f6a75c75e68ace0e6008439d4b271307d0d378 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 14:29:52 +0800 Subject: [PATCH 22/50] feat: extract shared foundry ExpandEnv into core pkg/foundry --- .../internal/project/templating.go | 67 ++--------------- cli/azd/pkg/foundry/templating.go | 72 +++++++++++++++++++ .../foundry}/templating_test.go | 2 +- 3 files changed, 79 insertions(+), 62 deletions(-) create mode 100644 cli/azd/pkg/foundry/templating.go rename cli/azd/{extensions/azure.ai.agents/internal/project => pkg/foundry}/templating_test.go (99%) 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/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" From b660ccdf7f0b1421893d2757def6aa2d94201871 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 14:45:03 +0800 Subject: [PATCH 23/50] ci: allowlist example identifiers for cspell --- cli/azd/.vscode/cspell.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 96ca432d72e..97bfb8c4369 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -9,6 +9,9 @@ words: - lightspeed - runewidth - toplevel + - myacr + - myconn + - myorg - azcloud - azdext - azdxignore From 27f216361849484cff762f2daa2ee2aaa03e3a2d Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 15:27:05 +0800 Subject: [PATCH 24/50] fix: clarify ref helper docs for review --- .../azure.ai.agents/internal/project/includes.go | 6 +++--- .../azure.ai.agents/internal/project/includes_edit.go | 4 ++-- .../internal/project/includes_edit_test.go | 10 ++++------ 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/includes.go b/cli/azd/extensions/azure.ai.agents/internal/project/includes.go index 79c42a8885c..5407bf757f5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/includes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/includes.go @@ -37,9 +37,9 @@ var remoteRefPattern = regexp.MustCompile(`(?i)^[a-z][a-z0-9+.-]*://`) // map[string]any. The core ServiceConfig fields (host, the service key, uses) are stripped by // core and never appear here. cfg therefore takes any of these shapes: // -// - A service-entry-level $ref. The $ref sits at the top level of the inline map, beside the -// host and service key that core already removed (e.g. an agent or skill entry whose body -// lives in ./agents/research-agent.yaml). The map itself is the $ref directive. +// - A service-entry-level $ref. In azure.yaml the service entry can be authored with host: +// plus $ref (and optional overlay keys). Core removes ServiceConfig fields such as host +// before the extension sees the config, so the cfg map passed here has $ref at its top level. // - A deployment array-item $ref. Deployments stay an array on the project service, so each // item in deployments may be its own $ref (e.g. ./deployments/gpt-4o.yaml). // - Any nested $ref reached while walking the entry (a $ref inside a loaded file, or a sibling diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit.go b/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit.go index f1695fdba94..6608cdc0f8e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit.go @@ -43,8 +43,8 @@ const ( // // It is the $ref-aware write counterpart to ResolveFileRefs: EntryRef recognizes a $ref entry // with the same key the resolver uses, and EditRefFile resolves the split-file path with the -// resolver's shared path logic, so reads and writes of $ref entries agree. The composition -// commands tracked by #8049 share this helper. +// resolver's shared path logic, so reads and writes of $ref entries agree. It is also intended +// for the #8049 composition command write path. // // Edits mutate the tree in memory; call Save to persist. Writes through EditRefFile lazily load // the referenced file, and Save persists every file touched this way alongside the main one. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go index f85352d95c4..c7c4e1655ca 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/includes_edit_test.go @@ -148,11 +148,9 @@ services: assert.False(t, isRef) } -// TestEntryRef_EdgeCases documents the intentional divergence from the resolver for -// degenerate $ref values: EntryRef falls back to (false) rather than erroring, so the -// write path treats the entry as inline. The read path (ResolveFileRefs) will surface -// CodeInvalidFileRef on these same inputs — the divergence is safe because the write -// still lands somewhere, and the read failure gives a clear diagnostic. +// TestEntryRef_EdgeCases documents EntryRef's narrow detection behavior: empty or whitespace-only +// $ref values fall back to not-a-ref so the write helper treats the entry as inline, while scalar +// values such as 123 are reported as refs and fail later if the referenced file is invalid. func TestEntryRef_EdgeCases(t *testing.T) { tests := []struct { name string @@ -173,7 +171,7 @@ func TestEntryRef_EdgeCases(t *testing.T) { yaml: "services:\n svc:\n kind: prompt\n", }, { - name: "numeric $ref value is coerced to string — treated as ref", + name: "numeric $ref value parses as scalar string - treated as ref", yaml: "services:\n svc:\n $ref: 123\n", wantRef: true, wantVal: "123", From be9e0073843991c842fb7e86ab55a208e7c9ae18 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 17:10:18 +0800 Subject: [PATCH 25/50] fix: allow inline foundry agent fields in agent schema --- .../schemas/azure.ai.agent.json | 88 ++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) 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", From b59dd1be1bdda7cb4335805c26a07fdbba6fcd45 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 17:10:19 +0800 Subject: [PATCH 26/50] fix: round-trip foundry agent image and validate inline shape --- .../azure.ai.agents/internal/cmd/init.go | 1 + .../internal/cmd/init_from_code.go | 1 + .../internal/project/agent_definition.go | 64 +++++++++++----- .../internal/project/agent_definition_test.go | 73 +++++++++++++++++++ 4 files changed, 119 insertions(+), 20 deletions(-) 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 384b0a6c1cd..91d92139828 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -2953,6 +2953,7 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa RelativePath: targetDir, Host: AiAgentHost, Language: "docker", + Image: containerDef.Image, AdditionalProperties: agentProps, } 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 f8425bad29b..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 @@ -843,6 +843,7 @@ func (a *InitFromCodeAction) addToProject( RelativePath: targetDir, Host: AiAgentHost, Language: language, + Image: definition.Image, AdditionalProperties: agentProps, } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index da31a939476..d49dabbf09f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -69,14 +69,16 @@ func WarnLegacyAgentShape(source AgentDefinitionSource) { // 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 the CPU/memory Resources, -// which are carried in the `container` config to avoid a key/type collision with -// the tool Resources list ([ServiceTargetAgentConfig.Resources], also keyed -// `resources`). The embedded [agent_yaml.AgentDefinition] promotes kind, name, -// description, and the schema fields to the top level. +// 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"` - Image string `json:"image,omitempty"` Protocols []agent_yaml.ProtocolVersionRecord `json:"protocols,omitempty"` EnvironmentVariables *[]agent_yaml.EnvironmentVariable `json:"environmentVariables,omitempty"` AgentEndpoint *agent_yaml.AgentEndpoint `json:"agentEndpoint,omitempty"` @@ -85,13 +87,13 @@ type AgentDefinitionInline struct { Policies []agent_yaml.Policy `json:"policies,omitempty"` } -// agentDefinitionToInline splits a ContainerAgent into the inline definition. -// The CPU/memory Resources are returned separately so the caller can store them -// in the `container` config. -func agentDefinitionToInline(ca agent_yaml.ContainerAgent) (AgentDefinitionInline, *ContainerSettings) { +// 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, - Image: ca.Image, Protocols: ca.Protocols, EnvironmentVariables: ca.EnvironmentVariables, AgentEndpoint: ca.AgentEndpoint, @@ -107,15 +109,16 @@ func agentDefinitionToInline(ca agent_yaml.ContainerAgent) (AgentDefinitionInlin } } - return inline, container + return inline, container, ca.Image } // toContainerAgent rebuilds the agent_yaml.ContainerAgent from the inline -// definition plus the CPU/memory carried in the `container` config. -func (d AgentDefinitionInline) toContainerAgent(container *ContainerSettings) agent_yaml.ContainerAgent { +// 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: d.Image, + Image: image, Protocols: d.Protocols, EnvironmentVariables: d.EnvironmentVariables, AgentEndpoint: d.AgentEndpoint, @@ -191,7 +194,7 @@ func AgentDefinitionFromService( } } - ca, isHosted, err := agentDefinitionFromStruct(inlineStruct) + ca, isHosted, err := agentDefinitionFromStruct(inlineStruct, svc.GetImage()) return ca, isHosted, true, source, err } @@ -309,8 +312,10 @@ func SetAgentContainerSettings(svc *azdext.ServiceConfig, container *ContainerSe } // agentDefinitionFromStruct builds the ContainerAgent from an inline/config -// struct that carries the agent definition as service-level properties. -func agentDefinitionFromStruct(s *structpb.Struct) (agent_yaml.ContainerAgent, bool, error) { +// 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( @@ -333,7 +338,22 @@ func agentDefinitionFromStruct(s *structpb.Struct) (agent_yaml.ContainerAgent, b ) } - ca := inline.toContainerAgent(cfg.Container) + 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 { + 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, @@ -433,11 +453,15 @@ func parseContainerAgentYAML(data []byte) (agent_yaml.ContainerAgent, bool, erro // 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) + inline, container, _ := agentDefinitionToInline(ca) defStruct, err := MarshalStruct(&inline) if err != 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 index 6cf0ee32f1b..7bd880f29a5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -119,6 +119,79 @@ func TestAgentDefinitionFromService_NoDefinition(t *testing.T) { 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) { From 58cfda515aca04e4d0e5443b3baa241501bffcc1 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 17:10:20 +0800 Subject: [PATCH 27/50] fix: bound optimize apply service path to project root --- .../azure.ai.agents/internal/cmd/optimize_apply.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 3b61bda9aef..4f2f9c0f792 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,7 @@ 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" @@ -118,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) From f2b6daeda91d1b92ca26bdf40d5b36d36a596173 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 17:10:21 +0800 Subject: [PATCH 28/50] docs: add foundry legacy agent shape to telemetry matrix --- docs/specs/metrics-audit/feature-telemetry-matrix.md | 1 + 1 file changed, 1 insertion(+) 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 | From 8a1429969f47e07b7e9b12741ef4eef17ce75728 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 17:22:42 +0800 Subject: [PATCH 29/50] feat: add azure.ai resource host schemas for Foundry services --- .../schemas/azure.ai.connection.json | 50 ++++++++ .../schemas/azure.ai.project.json | 67 +++++++++++ .../schemas/azure.ai.routine.json | 44 +++++++ .../schemas/azure.ai.skill.json | 23 ++++ .../schemas/azure.ai.toolbox.json | 33 ++++++ schemas/alpha/azure.yaml.json | 107 +++++++++++++++++- schemas/v1.0/azure.yaml.json | 107 +++++++++++++++++- 7 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json create mode 100644 cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json create mode 100644 cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json create mode 100644 cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json create mode 100644 cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json 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..7668359d5fe --- /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/huimiu/foundry-azure-yaml/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 upserts the connection on the project the entry uses:.", + "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.projects/schemas/azure.ai.project.json b/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json new file mode 100644 index 00000000000..52802274aaa --- /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/huimiu/foundry-azure-yaml/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 and URLs are also accepted." + } + } + } + } +} 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..925cb5115b7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json @@ -0,0 +1,44 @@ +{ + "$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.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. A routine service must uses: the agent it references.", + "type": "object", + "additionalProperties": true, + "properties": { + "description": { + "type": "string", + "description": "Description of the routine." + }, + "trigger": { + "type": "object", + "description": "What fires the routine.", + "required": ["type"], + "additionalProperties": true, + "allOf": [ + { + "if": { "properties": { "type": { "const": "schedule" } }, "required": ["type"] }, + "then": { "required": ["cron"] } + }, + { + "if": { "properties": { "type": { "enum": ["webhook", "event"] } }, "required": ["type"] }, + "then": { "required": ["filter"] } + } + ], + "properties": { + "type": { "type": "string", "enum": ["schedule", "webhook", "event"] }, + "cron": { "type": "string", "description": "Cron expression. Required for type: schedule." }, + "filter": { "type": "object", "description": "Event/webhook filter. Required for type: webhook | event." } + } + }, + "agent": { + "type": "string", + "description": "Name of the azure.ai.agent service the routine invokes." + }, + "input": { + "type": "object", + "description": "Input payload passed to the agent. Values may use ${VAR} (azd env, resolved client-side) or ${{...}} (Foundry server-side, passed through untouched).", + "additionalProperties": true + } + } +} 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..6a49d76f86b --- /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/huimiu/foundry-azure-yaml/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 file that declares it. ${VAR} and ${{...}} expansion applies to the loaded text." + }, + "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/schemas/azure.ai.toolbox.json b/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json new file mode 100644 index 00000000000..481f096c711 --- /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/huimiu/foundry-azure-yaml/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/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index 1df6f4bb2ab..476320c0368 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -230,7 +230,12 @@ "staticwebapp", "aks", "ai.endpoint", - "azure.ai.agent" + "azure.ai.agent", + "azure.ai.project", + "azure.ai.connection", + "azure.ai.toolbox", + "azure.ai.skill", + "azure.ai.routine" ] }, "language": { @@ -428,6 +433,106 @@ } } }, + { + "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/huimiu/foundry-azure-yaml/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/huimiu/foundry-azure-yaml/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/huimiu/foundry-azure-yaml/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/huimiu/foundry-azure-yaml/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/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json" } + ], + "properties": { + "project": false, + "runtime": false, + "docker": false, + "image": false, + "config": false + } + } + }, { "comment": "Traditional hosts - require project only, disable container-specific properties", "if": { diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 838af5417bd..80e62e5e55c 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -191,7 +191,12 @@ "staticwebapp", "aks", "ai.endpoint", - "azure.ai.agent" + "azure.ai.agent", + "azure.ai.project", + "azure.ai.connection", + "azure.ai.toolbox", + "azure.ai.skill", + "azure.ai.routine" ] }, "language": { @@ -388,6 +393,106 @@ } } }, + { + "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/huimiu/foundry-azure-yaml/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/huimiu/foundry-azure-yaml/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/huimiu/foundry-azure-yaml/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/huimiu/foundry-azure-yaml/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/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json" } + ], + "properties": { + "project": false, + "runtime": false, + "docker": false, + "image": false, + "config": false + } + } + }, { "comment": "Traditional hosts - require project only, disable container-specific properties", "if": { From 560b8df08b67a17237296b2488130e1c5a00a26c Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 17:29:25 +0800 Subject: [PATCH 30/50] feat: write Foundry resource services at service level --- .../internal/cmd/resource_services.go | 28 ++++++------ .../internal/cmd/resource_services_test.go | 43 +++++++++++++++++++ 2 files changed, 59 insertions(+), 12 deletions(-) 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 2dcf87d8647..1a1d37254c7 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 @@ -130,9 +130,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 +143,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 { @@ -220,11 +221,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 +253,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 +285,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 { 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) +} From 5d547815571e76f8185cbb6c67c175ee75039edf Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 17:45:16 +0800 Subject: [PATCH 31/50] feat: add azure.ai.skill and azure.ai.routine service targets --- .../azure.ai.routines/extension.yaml | 5 + .../azure.ai.routines/internal/cmd/root.go | 11 ++ .../internal/cmd/service_target.go | 166 +++++++++++++++++ .../internal/cmd/service_target_test.go | 59 ++++++ .../schemas/azure.ai.routine.json | 49 +++-- .../extensions/azure.ai.skills/extension.yaml | 5 + .../azure.ai.skills/internal/cmd/root.go | 12 +- .../internal/cmd/service_target.go | 169 ++++++++++++++++++ .../internal/cmd/service_target_test.go | 61 +++++++ 9 files changed, 509 insertions(+), 28 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go create mode 100644 cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/service_target.go create mode 100644 cli/azd/extensions/azure.ai.skills/internal/cmd/service_target_test.go diff --git a/cli/azd/extensions/azure.ai.routines/extension.yaml b/cli/azd/extensions/azure.ai.routines/extension.yaml index a50fd98ded9..5359ef36f0c 100644 --- a/cli/azd/extensions/azure.ai.routines/extension.yaml +++ b/cli/azd/extensions/azure.ai.routines/extension.yaml @@ -1,12 +1,17 @@ # 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 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..1e0197dbc30 --- /dev/null +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go @@ -0,0 +1,166 @@ +// 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" +) + +// 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() + + 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 +} 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 index 925cb5115b7..6e065fd5c37 100644 --- a/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json +++ b/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json @@ -2,7 +2,7 @@ "$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.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. A routine service must uses: the agent it references.", + "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": { @@ -10,35 +10,34 @@ "type": "string", "description": "Description of the routine." }, - "trigger": { + "enabled": { + "type": "boolean", + "description": "Whether the routine is enabled." + }, + "triggers": { "type": "object", - "description": "What fires the routine.", - "required": ["type"], - "additionalProperties": true, - "allOf": [ - { - "if": { "properties": { "type": { "const": "schedule" } }, "required": ["type"] }, - "then": { "required": ["cron"] } - }, - { - "if": { "properties": { "type": { "enum": ["webhook", "event"] } }, "required": ["type"] }, - "then": { "required": ["filter"] } + "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." } } - ], - "properties": { - "type": { "type": "string", "enum": ["schedule", "webhook", "event"] }, - "cron": { "type": "string", "description": "Cron expression. Required for type: schedule." }, - "filter": { "type": "object", "description": "Event/webhook filter. Required for type: webhook | event." } } }, - "agent": { - "type": "string", - "description": "Name of the azure.ai.agent service the routine invokes." - }, - "input": { + "action": { "type": "object", - "description": "Input payload passed to the agent. Values may use ${VAR} (azd env, resolved client-side) or ${{...}} (Foundry server-side, passed through untouched).", - "additionalProperties": true + "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.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..01da553bbcd --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/service_target.go @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + + "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 +// inline instructions. The call is idempotent: re-running deploy creates a new +// 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 + } + // TODO(#8049 follow-up): when instructions is a file path (.md/.txt), load + // the file contents at deploy time, rebasing relative paths to the file that + // declares them (spec 2.4). For now only inline instruction text is upserted. + if cfg.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: cfg.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 +} 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..c008b12ad09 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/service_target_test.go @@ -0,0 +1,61 @@ +// 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 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) +} From fb0b1bffce202df70cb8696e521c26cdea29099e Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 18:01:42 +0800 Subject: [PATCH 32/50] fix: remove PR reference from TODO comment --- .../azure.ai.skills/internal/cmd/service_target.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 01da553bbcd..9a1392848f5 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/service_target.go @@ -112,9 +112,9 @@ func (p *skillServiceTarget) Deploy( if err != nil { return nil, err } - // TODO(#8049 follow-up): when instructions is a file path (.md/.txt), load - // the file contents at deploy time, rebasing relative paths to the file that - // declares them (spec 2.4). For now only inline instruction text is upserted. + // TODO: when instructions is a file path (.md/.txt), load the file contents + // at deploy time, rebasing relative paths to the declaring file. + // For now only inline instruction text is upserted. if cfg.Instructions == "" { return nil, fmt.Errorf("skill service %q requires instructions", serviceConfig.GetName()) } From 6a686153cab21afccbb06e97e507591257ca9fb1 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 18:57:07 +0800 Subject: [PATCH 33/50] fix: honor foundry agent definition override --- .../azure.ai.agents/internal/cmd/update.go | 8 +++-- .../internal/project/service_target_agent.go | 36 +++++++++++-------- .../project/service_target_agent_test.go | 28 +++++++++++++++ 3 files changed, 55 insertions(+), 17 deletions(-) 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 beb78e76c13..431c676a459 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/update.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/update.go @@ -46,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 @@ -103,7 +104,8 @@ func runEndpointUpdate( // Validate that endpoint or card is defined. if agentDef.AgentEndpoint == nil && agentDef.AgentCard == nil { return fmt.Errorf( - "agent 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/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 21f78f51e92..31e614da3f6 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 @@ -961,10 +961,24 @@ func hasContainerArtifact(artifacts []*azdext.Artifact) bool { } func (p *AgentServiceTargetProvider) loadContainerAgentDefinition() (agent_yaml.ContainerAgent, bool, error) { + // 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", + ) + } + + WarnLegacyAgentShape(AgentDefinitionSourceDisk) + return parseContainerAgentYAML(data) + } + // Prefer the agent definition carried inline on the service entry (the - // unified service-level shape, or the deprecated config-nested shape). Fall - // back to a legacy agent.yaml/agent.yml on disk so older projects still build - // and deploy during the deprecation window. + // 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) @@ -972,17 +986,11 @@ func (p *AgentServiceTargetProvider) loadContainerAgentDefinition() (agent_yaml. return ca, isHosted, err } - 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", - ) - } - - WarnLegacyAgentShape(AgentDefinitionSourceDisk) - return parseContainerAgentYAML(data) + 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 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() From c76ed91fd622292d976c8ce4bf478ffdd705115a Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 22:04:14 +0800 Subject: [PATCH 34/50] fix: address foundry config review feedback --- cli/azd/extensions/azure.ai.agents/README.md | 35 +++++++++++++ .../internal/project/agent_definition.go | 3 +- .../internal/cmd/service_target.go | 49 ++++++++++++++++--- .../internal/cmd/service_target_test.go | 24 +++++++++ .../internal/cmd/skill_create.go | 10 ++-- .../schemas/azure.ai.skill.json | 2 +- 6 files changed, 108 insertions(+), 15 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index 0573957e075..9aa2e8134f3 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 +``` + ## Local Development ### Prerequisites diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index d49dabbf09f..fdc3da664c7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -40,7 +40,8 @@ func (s AgentDefinitionSource) IsLegacy() bool { // MigrationGuideURL points at guidance for migrating older Foundry agent // projects onto the unified azure.yaml shape. -const MigrationGuideURL = "https://github.com/Azure/azure-dev/issues/8773" +const MigrationGuideURL = "https://github.com/Azure/azure-dev/tree/main/cli/azd/extensions/" + + "azure.ai.agents#migrating-legacy-agent-configuration" var legacyAgentShapeWarnOnce sync.Once 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 index 9a1392848f5..b5d5de25f8b 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/service_target.go @@ -7,6 +7,8 @@ import ( "context" "encoding/json" "fmt" + "path/filepath" + "strings" "azureaiskills/internal/pkg/skill_api" @@ -98,9 +100,9 @@ func (p *skillServiceTarget) Publish( } // Deploy upserts the skill by creating a new default version from the entry's -// inline instructions. The call is idempotent: re-running deploy creates a new -// 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`). +// 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, @@ -112,10 +114,11 @@ func (p *skillServiceTarget) Deploy( if err != nil { return nil, err } - // TODO: when instructions is a file path (.md/.txt), load the file contents - // at deploy time, rebasing relative paths to the declaring file. - // For now only inline instruction text is upserted. - if cfg.Instructions == "" { + 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()) } @@ -134,7 +137,7 @@ func (p *skillServiceTarget) Deploy( skill_api.CreateVersionRequest{ InlineContent: &skill_api.SkillInlineContent{ Description: cfg.Description, - Instructions: cfg.Instructions, + Instructions: instructions, AllowedTools: cfg.Tools, }, Default: true, @@ -167,3 +170,33 @@ func parseSkillServiceConfig(svc *azdext.ServiceConfig) (*skillServiceConfig, er } 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) { + baseDir := svc.GetRelativePath() + if baseDir == "" { + baseDir = "." + } + path = filepath.Join(baseDir, path) + } + + data, err := readFileWithLimit(path) + if err != nil { + return "", err + } + return string(data), nil +} + +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 index c008b12ad09..fc668382e32 100644 --- 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 @@ -4,6 +4,8 @@ package cmd import ( + "os" + "path/filepath" "testing" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -59,3 +61,25 @@ func TestParseSkillServiceConfig_Empty(t *testing.T) { 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) +} 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 index 6a49d76f86b..099329ed4b1 100644 --- a/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json +++ b/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json @@ -12,7 +12,7 @@ }, "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 file that declares it. ${VAR} and ${{...}} expansion applies to the loaded text." + "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", From 01463ffb5bd9c867c6f91733f8418fa997303600 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 22:40:46 +0800 Subject: [PATCH 35/50] fix: address unresolved foundry review comments --- cli/azd/cmd/telemetry_coverage_test.go | 8 + .../internal/cmd/optimize_apply.go | 4 +- .../internal/project/agent_definition.go | 2 +- .../internal/project/service_target_agent.go | 2 - .../azure.ai.agents/schemas/Agent.json | 2 +- .../azure.ai.agents/schemas/Connection.json | 2 +- .../azure.ai.agents/schemas/Deployment.json | 2 +- .../azure.ai.agents/schemas/FileRef.json | 2 +- .../azure.ai.agents/schemas/README.md | 157 +++++++----------- .../azure.ai.agents/schemas/Routine.json | 2 +- .../azure.ai.agents/schemas/Skill.json | 2 +- .../azure.ai.agents/schemas/Toolbox.json | 2 +- .../schemas/examples/complex.azure.yaml | 2 +- .../schemas/examples/simple.azure.yaml | 2 +- .../schemas/azure.ai.connection.json | 4 +- .../schemas/azure.ai.project.json | 2 +- .../schemas/azure.ai.routine.json | 2 +- .../schemas/azure.ai.skill.json | 2 +- .../schemas/azure.ai.toolbox.json | 2 +- schemas/alpha/azure.yaml.json | 14 +- schemas/v1.0/azure.yaml.json | 14 +- 21 files changed, 98 insertions(+), 133 deletions(-) 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/internal/cmd/optimize_apply.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go index 4f2f9c0f792..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 @@ -173,7 +173,9 @@ func (a *OptimizeApplyAction) apply( "OPTIMIZATION_LOCAL_DIR": agentConfigsDir, "OPTIMIZATION_CANDIDATE_ID": a.flags.candidate, } - if _, _, found, _, _ := projectpkg.AgentDefinitionFromService(svc); found { + 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) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index fdc3da664c7..bad48a5d96f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -57,7 +57,7 @@ func WarnLegacyAgentShape(source AgentDefinitionSource) { legacyAgentShapeWarnOnce.Do(func() { detail := "the deprecated config-nested azure.ai.agent shape" if source == AgentDefinitionSourceDisk { - detail = "an on-disk agent.yaml/agent.manifest.yaml" + 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; "+ 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 31e614da3f6..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 @@ -270,8 +270,6 @@ 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 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..cc72eac6938 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", diff --git a/cli/azd/extensions/azure.ai.agents/schemas/README.md b/cli/azd/extensions/azure.ai.agents/schemas/README.md index b04d4e44654..b81e2bf6c7a 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/README.md +++ b/cli/azd/extensions/azure.ai.agents/schemas/README.md @@ -1,100 +1,57 @@ -# 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'); +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 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/examples/complex.azure.yaml b/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml index 63878d29ffc..61ca97cb144 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/schemas/azure.ai.connection.json b/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json index 7668359d5fe..f51ea03067e 100644 --- a/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json +++ b/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json @@ -1,8 +1,8 @@ { "$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.connections/schemas/azure.ai.connection.json", + "$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 upserts the connection on the project the entry uses:.", + "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": { 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 index 52802274aaa..7a2799f6ec8 100644 --- a/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json +++ b/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.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.projects/schemas/azure.ai.project.json", + "$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", 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 index 6e065fd5c37..40058c5dfaa 100644 --- a/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json +++ b/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.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.routines/schemas/azure.ai.routine.json", + "$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", 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 index 099329ed4b1..ce4f6bf76bc 100644 --- a/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json +++ b/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.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.skills/schemas/azure.ai.skill.json", + "$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", 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 index 481f096c711..46b35dbfd68 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json +++ b/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.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.toolboxes/schemas/azure.ai.toolbox.json", + "$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", diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index 476320c0368..84409af93e8 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -424,7 +424,7 @@ "then": { "required": ["project"], "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json" } + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json" } ], "properties": { "config": false, @@ -442,7 +442,7 @@ }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json" } + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json" } ], "properties": { "project": false, @@ -462,7 +462,7 @@ }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json" } + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json" } ], "properties": { "project": false, @@ -482,7 +482,7 @@ }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json" } + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json" } ], "properties": { "project": false, @@ -502,7 +502,7 @@ }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json" } + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json" } ], "properties": { "project": false, @@ -522,7 +522,7 @@ }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json" } + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json" } ], "properties": { "project": false, @@ -1178,7 +1178,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 80e62e5e55c..bfb59ed70ba 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -384,7 +384,7 @@ "then": { "required": ["project"], "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json" } + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json" } ], "properties": { "config": false, @@ -402,7 +402,7 @@ }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json" } + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json" } ], "properties": { "project": false, @@ -422,7 +422,7 @@ }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json" } + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json" } ], "properties": { "project": false, @@ -442,7 +442,7 @@ }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json" } + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json" } ], "properties": { "project": false, @@ -462,7 +462,7 @@ }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json" } + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json" } ], "properties": { "project": false, @@ -482,7 +482,7 @@ }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/huimiu/foundry-azure-yaml/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json" } + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json" } ], "properties": { "project": false, @@ -1138,7 +1138,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", From 8f2478fe0a56a0296861d280e29de637a7e70404 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 23:30:45 +0800 Subject: [PATCH 36/50] feat: move foundry $ref resolver and edit helper to pkg/foundry --- cli/azd/pkg/foundry/errors.go | 31 ++++++++++++++++++ .../project => pkg/foundry}/includes.go | 32 +++++++------------ .../project => pkg/foundry}/includes_edit.go | 9 ++---- .../foundry}/includes_edit_test.go | 2 +- .../project => pkg/foundry}/includes_test.go | 5 ++- 5 files changed, 48 insertions(+), 31 deletions(-) create mode 100644 cli/azd/pkg/foundry/errors.go rename cli/azd/{extensions/azure.ai.agents/internal/project => pkg/foundry}/includes.go (93%) rename cli/azd/{extensions/azure.ai.agents/internal/project => pkg/foundry}/includes_edit.go (98%) rename cli/azd/{extensions/azure.ai.agents/internal/project => pkg/foundry}/includes_edit_test.go (99%) rename cli/azd/{extensions/azure.ai.agents/internal/project => pkg/foundry}/includes_test.go (99%) 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..9dfea6cf347 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" @@ -13,7 +13,6 @@ import ( "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 +73,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 +133,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 +145,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 +171,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 +198,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 +222,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 +230,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..9abae0efed4 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" @@ -15,7 +15,6 @@ import ( "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 +59,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 +79,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..7930dda7201 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" @@ -13,7 +13,6 @@ import ( "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 +40,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) } From 4eacfc21371336d18c4b46875b17dcbb131e774b Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 23:39:35 +0800 Subject: [PATCH 37/50] feat: add azure.ai.connection deploy-time service target --- .../azure.ai.connections/extension.yaml | 5 + .../extensions/azure.ai.connections/go.mod | 18 +- .../extensions/azure.ai.connections/go.sum | 28 +- .../azure.ai.connections/internal/cmd/root.go | 11 + .../internal/cmd/service_target.go | 273 ++++++++++++++++++ .../internal/cmd/service_target_test.go | 108 +++++++ 6 files changed, 426 insertions(+), 17 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.connections/internal/cmd/service_target.go create mode 100644 cli/azd/extensions/azure.ai.connections/internal/cmd/service_target_test.go diff --git a/cli/azd/extensions/azure.ai.connections/extension.yaml b/cli/azd/extensions/azure.ai.connections/extension.yaml index 0a2c0f2382c..e2f26060794 100644 --- a/cli/azd/extensions/azure.ai.connections/extension.yaml +++ b/cli/azd/extensions/azure.ai.connections/extension.yaml @@ -1,12 +1,17 @@ # 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 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) +} From e4fbeb1d05568a3bd585b3266118dce54c33fefa Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 23:44:41 +0800 Subject: [PATCH 38/50] feat: add azure.ai.toolbox deploy-time service target --- .../azure.ai.toolboxes/extension.yaml | 5 + cli/azd/extensions/azure.ai.toolboxes/go.mod | 22 +- cli/azd/extensions/azure.ai.toolboxes/go.sum | 28 +- .../azure.ai.toolboxes/internal/cmd/root.go | 11 + .../internal/cmd/service_target.go | 268 ++++++++++++++++++ .../internal/cmd/service_target_test.go | 94 ++++++ 6 files changed, 409 insertions(+), 19 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go create mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go diff --git a/cli/azd/extensions/azure.ai.toolboxes/extension.yaml b/cli/azd/extensions/azure.ai.toolboxes/extension.yaml index 16215ae4ab8..9858789da24 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/extension.yaml +++ b/cli/azd/extensions/azure.ai.toolboxes/extension.yaml @@ -1,12 +1,17 @@ # 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 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"]) +} From a427e80fdd885012dab4a145da7eec06f2e794f7 Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 23:46:14 +0800 Subject: [PATCH 39/50] feat: add azure.ai.project host service target --- .../azure.ai.projects/extension.yaml | 5 + .../azure.ai.projects/internal/cmd/root.go | 12 ++ .../internal/cmd/service_target.go | 105 ++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/service_target.go diff --git a/cli/azd/extensions/azure.ai.projects/extension.yaml b/cli/azd/extensions/azure.ai.projects/extension.yaml index 58914fd54c5..7a90d7fd92c 100644 --- a/cli/azd/extensions/azure.ai.projects/extension.yaml +++ b/cli/azd/extensions/azure.ai.projects/extension.yaml @@ -1,12 +1,17 @@ # 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 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 +} From c387a847f7e4c38cef286544d206a513b1e5e52e Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 23 Jun 2026 23:52:54 +0800 Subject: [PATCH 40/50] feat: support endpoint: brownfield reuse for foundry --- .../project/foundry_provisioning_provider.go | 118 ++++++++++++++---- .../foundry_provisioning_provider_test.go | 65 ++++++---- 2 files changed, 135 insertions(+), 48 deletions(-) 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 4cda4411750..73871ecfc09 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" @@ -69,6 +70,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 } @@ -124,9 +130,11 @@ 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 != "" { + p.brownfieldEndpoint = endpoint + return p.resolveEnvName(ctx) } return p.resolveEnv(ctx) } @@ -138,12 +146,9 @@ func (p *FoundryProvisioningProvider) Initialize( }) switch { case errors.Is(err, synthesis.ErrEndpointBrownfield): - 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", - ) + // endpoint: reuse — connect to the existing project, skip provisioning. + p.brownfieldEndpoint = foundryServiceEndpoint(rawYAML, svcName) + return p.resolveEnvName(ctx) case errors.Is(err, synthesis.ErrServiceNotFound): return exterrors.Dependency( exterrors.CodeProvisioningServiceNotFound, @@ -186,9 +191,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"` } @@ -198,17 +205,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 @@ -319,6 +364,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 @@ -354,6 +408,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...") src, err := p.resolveTemplate(ctx, progress) @@ -527,6 +590,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) @@ -600,6 +670,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 9ad4a5c19b3..b9bf4531711 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 @@ -709,65 +709,76 @@ 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: "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 From 6e7d04d27da91f6ac17e5669770b7168b385b1c5 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 24 Jun 2026 00:02:53 +0800 Subject: [PATCH 41/50] feat: remove agent no-op hosts; reconcile toolboxes at deploy --- .../azure.ai.agents/internal/cmd/listen.go | 210 +----------------- .../project/service_target_resource.go | 106 --------- 2 files changed, 4 insertions(+), 312 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/service_target_resource.go 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 fbe7a85a2f1..796a8383c5a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -9,15 +9,12 @@ import ( "errors" "fmt" "log" - "net/url" "os" "strings" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/optimize_api" - "azureaiagent/internal/pkg/azure" - "azureaiagent/internal/pkg/envkey" "azureaiagent/internal/project" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" @@ -36,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) }). @@ -99,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 { @@ -689,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/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 -} From e9d44a222d61d6f6921a0c53fcf7e5bbd784fdaf Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 24 Jun 2026 00:07:00 +0800 Subject: [PATCH 42/50] feat: expand ${VAR} in routine action input at deploy --- cli/azd/extensions/azure.ai.routines/go.mod | 27 +++++---- cli/azd/extensions/azure.ai.routines/go.sum | 30 +++++----- .../internal/cmd/service_target.go | 59 +++++++++++++++++++ 3 files changed, 92 insertions(+), 24 deletions(-) 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/service_target.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go index 1e0197dbc30..e8e275ebbc0 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go @@ -12,6 +12,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" ) // aiRoutineHost is the azure.yaml service host kind owned by this extension. A @@ -110,6 +111,16 @@ func (p *routineServiceTarget) Deploy( // 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())) } @@ -164,3 +175,51 @@ func newRoutineServiceClient(ctx context.Context) (*routines.Client, error) { } 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 + } +} From c36baebfe8cb081e24ed30dbaf2b8404d68342b4 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 24 Jun 2026 00:20:19 +0800 Subject: [PATCH 43/50] ci: gofmt and add cspell words for foundry targets --- cli/azd/.vscode/cspell.yaml | 6 ++++++ cli/azd/pkg/foundry/includes.go | 1 - cli/azd/pkg/foundry/includes_edit.go | 1 - cli/azd/pkg/foundry/includes_test.go | 1 - 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 97bfb8c4369..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 diff --git a/cli/azd/pkg/foundry/includes.go b/cli/azd/pkg/foundry/includes.go index 9dfea6cf347..7cd27c2f20f 100644 --- a/cli/azd/pkg/foundry/includes.go +++ b/cli/azd/pkg/foundry/includes.go @@ -12,7 +12,6 @@ import ( "strings" "go.yaml.in/yaml/v3" - ) // refKey is the include directive key. Any object that contains it is replaced by the loaded diff --git a/cli/azd/pkg/foundry/includes_edit.go b/cli/azd/pkg/foundry/includes_edit.go index 9abae0efed4..620f6247a96 100644 --- a/cli/azd/pkg/foundry/includes_edit.go +++ b/cli/azd/pkg/foundry/includes_edit.go @@ -14,7 +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" - ) // ErrServiceNotFound is returned when a named service entry is absent and creation was not diff --git a/cli/azd/pkg/foundry/includes_test.go b/cli/azd/pkg/foundry/includes_test.go index 7930dda7201..43a0520bdd3 100644 --- a/cli/azd/pkg/foundry/includes_test.go +++ b/cli/azd/pkg/foundry/includes_test.go @@ -12,7 +12,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.yaml.in/yaml/v3" - ) // writeFile writes content to dir/name (creating parent directories) and returns nothing; it From 3465ce9db71a64ca97229bcc47a2c9b9d8f482e4 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 24 Jun 2026 00:23:39 +0800 Subject: [PATCH 44/50] ci: bump versions and changelogs for foundry targets --- cli/azd/extensions/azure.ai.agents/CHANGELOG.md | 2 ++ cli/azd/extensions/azure.ai.connections/CHANGELOG.md | 4 ++++ cli/azd/extensions/azure.ai.connections/extension.yaml | 2 +- cli/azd/extensions/azure.ai.connections/version.txt | 2 +- cli/azd/extensions/azure.ai.projects/CHANGELOG.md | 6 ++++++ cli/azd/extensions/azure.ai.projects/extension.yaml | 2 +- cli/azd/extensions/azure.ai.projects/version.txt | 2 +- cli/azd/extensions/azure.ai.routines/CHANGELOG.md | 6 ++++++ cli/azd/extensions/azure.ai.routines/extension.yaml | 2 +- cli/azd/extensions/azure.ai.routines/version.txt | 2 +- cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md | 4 ++++ cli/azd/extensions/azure.ai.toolboxes/extension.yaml | 2 +- cli/azd/extensions/azure.ai.toolboxes/version.txt | 2 +- 13 files changed, 30 insertions(+), 8 deletions(-) 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.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 e2f26060794..36ae49c7b3a 100644 --- a/cli/azd/extensions/azure.ai.connections/extension.yaml +++ b/cli/azd/extensions/azure.ai.connections/extension.yaml @@ -16,4 +16,4 @@ 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/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 7a90d7fd92c..603a4b0cef2 100644 --- a/cli/azd/extensions/azure.ai.projects/extension.yaml +++ b/cli/azd/extensions/azure.ai.projects/extension.yaml @@ -16,4 +16,4 @@ 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/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 5359ef36f0c..20358dbb62e 100644 --- a/cli/azd/extensions/azure.ai.routines/extension.yaml +++ b/cli/azd/extensions/azure.ai.routines/extension.yaml @@ -16,4 +16,4 @@ 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/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.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 9858789da24..612b8be3008 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/extension.yaml +++ b/cli/azd/extensions/azure.ai.toolboxes/extension.yaml @@ -16,4 +16,4 @@ 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/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 From d6fd4dacfec06430706c24c4cc120d329f57153c Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 24 Jun 2026 15:08:29 +0800 Subject: [PATCH 45/50] fix: restore microsoft.foundry host, brownfield warning, endpoint trimming --- .../project/foundry_provisioning_provider.go | 3 +- .../foundry_provisioning_provider_test.go | 30 +++++++++++++++++++ .../internal/project/provisioning_provider.go | 12 ++++---- .../internal/synthesis/synthesizer.go | 2 +- .../internal/synthesis/synthesizer_test.go | 10 +++++++ schemas/alpha/azure.yaml.json | 21 +++++++++++++ schemas/v1.0/azure.yaml.json | 21 +++++++++++++ 7 files changed, 92 insertions(+), 7 deletions(-) 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 22910bb9c0d..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 @@ -135,6 +135,7 @@ func (p *FoundryProvisioningProvider) Initialize( // 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) } @@ -234,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) } 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 4962ac8dc6b..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, }, @@ -746,6 +766,16 @@ services: 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 -> empty", yaml: `name: x 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/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/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index 84409af93e8..631227c0864 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -231,6 +231,7 @@ "aks", "ai.endpoint", "azure.ai.agent", + "microsoft.foundry", "azure.ai.project", "azure.ai.connection", "azure.ai.toolbox", @@ -533,6 +534,26 @@ } } }, + { + "comment": "Legacy Microsoft Foundry host - deprecated compatibility alias for azure.ai.agent provisioning shape", + "if": { + "properties": { + "host": { "const": "microsoft.foundry" } + } + }, + "then": { + "allOf": [ + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json" } + ], + "properties": { + "project": false, + "runtime": false, + "docker": false, + "image": false, + "config": false + } + } + }, { "comment": "Traditional hosts - require project only, disable container-specific properties", "if": { diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index bfb59ed70ba..5c9a5394ccf 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -192,6 +192,7 @@ "aks", "ai.endpoint", "azure.ai.agent", + "microsoft.foundry", "azure.ai.project", "azure.ai.connection", "azure.ai.toolbox", @@ -493,6 +494,26 @@ } } }, + { + "comment": "Legacy Microsoft Foundry host - deprecated compatibility alias for azure.ai.agent provisioning shape", + "if": { + "properties": { + "host": { "const": "microsoft.foundry" } + } + }, + "then": { + "allOf": [ + { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json" } + ], + "properties": { + "project": false, + "runtime": false, + "docker": false, + "image": false, + "config": false + } + } + }, { "comment": "Traditional hosts - require project only, disable container-specific properties", "if": { From 2be44d388912b0623cbb73eab59952e9a7ab5482 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 24 Jun 2026 17:37:42 +0800 Subject: [PATCH 46/50] fix: reject path traversal in skill instructions file path --- .../internal/cmd/service_target.go | 18 ++++++++++++++++++ .../internal/cmd/service_target_test.go | 16 ++++++++++++++++ 2 files changed, 34 insertions(+) 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 index b5d5de25f8b..c86aa86c5a3 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/service_target.go @@ -178,6 +178,13 @@ func resolveSkillInstructions(svc *azdext.ServiceConfig, instructions string) (s 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 = "." @@ -192,6 +199,17 @@ func resolveSkillInstructions(svc *azdext.ServiceConfig, instructions string) (s 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": 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 index fc668382e32..7655d6a7c57 100644 --- 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 @@ -83,3 +83,19 @@ func TestResolveSkillInstructions_FilePath(t *testing.T) { 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 '..'") + } +} From 991adc0d6d8b18dee09115a6183a52757568a0ee Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 24 Jun 2026 17:37:49 +0800 Subject: [PATCH 47/50] fix: drop unsupported URL claim from $ref schema docs --- cli/azd/extensions/azure.ai.agents/schemas/FileRef.json | 2 +- .../extensions/azure.ai.projects/schemas/azure.ai.project.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/schemas/FileRef.json b/cli/azd/extensions/azure.ai.agents/schemas/FileRef.json index cc72eac6938..b004897250b 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/FileRef.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/FileRef.json @@ -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.projects/schemas/azure.ai.project.json b/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json index 7a2799f6ec8..93d5fa9e5ce 100644 --- a/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json +++ b/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json @@ -59,7 +59,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." } } } From 6b61a1fa126c7c483b9192438f7ea3b6aaa7b112 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 24 Jun 2026 17:37:56 +0800 Subject: [PATCH 48/50] fix: register all foundry schemas in offline ajv check --- cli/azd/extensions/azure.ai.agents/schemas/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/schemas/README.md b/cli/azd/extensions/azure.ai.agents/schemas/README.md index b81e2bf6c7a..ccb23b93717 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/README.md +++ b/cli/azd/extensions/azure.ai.agents/schemas/README.md @@ -34,7 +34,10 @@ addFormats(ajv); ajv.addMetaSchema(require('ajv/dist/refs/json-schema-draft-07.json')); 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')); +// 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, '/'); From e32d6c6007aa6ee7a8ef28a0f8a382e005dc9a44 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 24 Jun 2026 17:38:04 +0800 Subject: [PATCH 49/50] fix: log inline agent definition marshal failures --- .../internal/project/agent_definition.go | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index bad48a5d96f..2b011ee05a6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -5,6 +5,7 @@ package project import ( "fmt" + "log" "os" "sync" @@ -345,14 +346,17 @@ func agentDefinitionFromStruct(s *structpb.Struct, coreImage string) (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 { - 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 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) { From 6260ede13372a3a45c7de8fc1efe97dc494d015f Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 24 Jun 2026 17:38:12 +0800 Subject: [PATCH 50/50] fix: warn when foundry resource name has no service key --- .../internal/cmd/resource_services.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) 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 1a1d37254c7..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 { @@ -186,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), " ", "") }