From 8c53106bd1ee6cbfd653cd4bfea28b6a5bd316e2 Mon Sep 17 00:00:00 2001 From: huimiu Date: Fri, 12 Jun 2026 17:11:51 +0800 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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"]) +}