diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index a52946615c8..d07f9cc4f36 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History +## Unreleased + +- Add support for a generic `policies` list in `agent.yaml` to attach governance policies to hosted agents. Each entry has a `type` discriminator; `type: rai_policy` attaches a Responsible AI (content safety) guardrail via `rai_policy_name`, the full ARM resource ID of the RAI policy (for example, `/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//raiPolicies/`). `azd deploy` forwards it to the Foundry data plane as `rai_config.rai_policy_name`. + ## 0.1.37-preview (2026-06-01) - [[#8512]](https://github.com/Azure/azure-dev/pull/8512) Normalize connection auth `AgenticIdentity` values to the ARM-required `AgenticIdentityToken`. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index 6f0dbf206fa..e9720dabcb5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -84,6 +84,18 @@ func constructBuildConfig(options ...AgentBuildOption) *AgentBuildConfig { return config } +// mapRaiConfig flattens the manifest-level policies list into the data-plane +// rai_config field. It returns the RAI config derived from the first policy of +// type "rai_policy" that has a policy name, or nil when none is configured. +func mapRaiConfig(policies []Policy) *agent_api.RaiConfig { + for _, policy := range policies { + if policy.Type == PolicyTypeRai && policy.RaiPolicyName != "" { + return &agent_api.RaiConfig{RaiPolicyName: policy.RaiPolicyName} + } + } + return nil +} + // MapEndpointAndCard maps YAML-layer endpoint and card fields to API model types // without requiring or validating the full agent definition. This is used by the // endpoint update command where only endpoint/card patching is needed. @@ -396,7 +408,8 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB codeDef := agent_api.HostedAgentDefinition{ AgentDefinition: agent_api.AgentDefinition{ - Kind: agent_api.AgentKindHosted, + Kind: agent_api.AgentKindHosted, + RaiConfig: mapRaiConfig(hostedAgent.Policies), }, ProtocolVersions: protocolVersions, CPU: cpu, @@ -420,7 +433,8 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB imageDef := agent_api.HostedAgentDefinition{ AgentDefinition: agent_api.AgentDefinition{ - Kind: agent_api.AgentKindHosted, + Kind: agent_api.AgentKindHosted, + RaiConfig: mapRaiConfig(hostedAgent.Policies), }, ProtocolVersions: protocolVersions, CPU: cpu, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go index 8961b86c8bd..c07fb7a10cb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go @@ -1534,3 +1534,108 @@ func TestCreateHostedAgentAPIRequest_WithVersionSelectorAndAuthSchemes(t *testin t.Errorf("schemes[1].IsolationKeySource should be nil, got %v", schemes[1].IsolationKeySource) } } + +func TestCreateHostedAgentAPIRequest_WithRaiConfig(t *testing.T) { + t.Parallel() + const raiPolicyID = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/" + + "my-rg/providers/Microsoft.CognitiveServices/accounts/my-account/raiPolicies/Microsoft.DefaultV2" + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindHosted, + Name: "rai-agent", + }, + Policies: []Policy{ + {Type: PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + } + buildConfig := &AgentBuildConfig{ImageURL: "img:latest"} + + req, err := CreateHostedAgentAPIRequest(agent, buildConfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + imgDef := req.Definition.(agent_api.HostedAgentDefinition) + if imgDef.RaiConfig == nil { + t.Fatal("expected RaiConfig to be set, got nil") + } + if imgDef.RaiConfig.RaiPolicyName != raiPolicyID { + t.Errorf("RaiPolicyName = %q, want %q", imgDef.RaiConfig.RaiPolicyName, raiPolicyID) + } +} + +func TestCreateAgentAPIRequest_CodeDeploy_WithRaiConfig(t *testing.T) { + t.Parallel() + const raiPolicyID = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/" + + "my-rg/providers/Microsoft.CognitiveServices/accounts/my-account/raiPolicies/Microsoft.DefaultV2" + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindHosted, + Name: "rai-code-agent", + }, + Protocols: []ProtocolVersionRecord{ + {Protocol: "responses", Version: "1.0.0"}, + }, + CodeConfiguration: &CodeConfiguration{ + Runtime: "python_3_12", + EntryPoint: "agent.py", + }, + Policies: []Policy{ + {Type: PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + } + + req, err := CreateAgentAPIRequestFromDefinition(agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + codeDef := req.Definition.(agent_api.HostedAgentDefinition) + if codeDef.RaiConfig == nil { + t.Fatal("expected RaiConfig to be set, got nil") + } + if codeDef.RaiConfig.RaiPolicyName != raiPolicyID { + t.Errorf("RaiPolicyName = %q, want %q", codeDef.RaiConfig.RaiPolicyName, raiPolicyID) + } +} + +func TestCreateHostedAgentAPIRequest_NoRaiConfig(t *testing.T) { + t.Parallel() + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindHosted, + Name: "no-rai-agent", + }, + } + buildConfig := &AgentBuildConfig{ImageURL: "img:latest"} + + req, err := CreateHostedAgentAPIRequest(agent, buildConfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + imgDef := req.Definition.(agent_api.HostedAgentDefinition) + if imgDef.RaiConfig != nil { + t.Errorf("expected RaiConfig to be nil, got %+v", imgDef.RaiConfig) + } +} + +func TestMapRaiConfig(t *testing.T) { + t.Parallel() + if got := mapRaiConfig(nil); got != nil { + t.Errorf("mapRaiConfig(nil) = %+v, want nil", got) + } + if got := mapRaiConfig([]Policy{}); got != nil { + t.Errorf("mapRaiConfig(empty policies) = %+v, want nil", got) + } + if got := mapRaiConfig([]Policy{{Type: PolicyTypeRai}}); got != nil { + t.Errorf("mapRaiConfig(empty policy name) = %+v, want nil", got) + } + if got := mapRaiConfig([]Policy{{Type: "other", RaiPolicyName: "p1"}}); got != nil { + t.Errorf("mapRaiConfig(non-rai type) = %+v, want nil", got) + } + got := mapRaiConfig([]Policy{{Type: PolicyTypeRai, RaiPolicyName: "p1"}}) + if got == nil || got.RaiPolicyName != "p1" { + t.Errorf("mapRaiConfig(p1) = %+v, want RaiPolicyName=p1", got) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go index d5189045574..2a9dd971239 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go @@ -384,6 +384,23 @@ func ValidateAgentDefinition(templateBytes []byte) error { case AgentKindHosted: var agent ContainerAgent if err := yaml.Unmarshal(templateBytes, &agent); err == nil { + for i, policy := range agent.Policies { + switch policy.Type { + case PolicyTypeRai: + if policy.RaiPolicyName == "" { + errors = append(errors, fmt.Sprintf( + "policies[%d] of type '%s' requires a policy name (rai_policy_name)", + i, policy.Type)) + } + case "": + errors = append(errors, fmt.Sprintf( + "policies[%d] requires a type", i)) + default: + errors = append(errors, fmt.Sprintf( + "policies[%d] has an unsupported type '%s' (supported: %s)", + i, policy.Type, PolicyTypeRai)) + } + } // TODO: Do we need this? // if len(agent.Models) == 0 { // errors = append(errors, "template.models is required and must not be empty") diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go index 46a5d454666..6129008d05b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go @@ -1099,3 +1099,90 @@ resources: t.Error("Expected a non-empty suggestion") } } + +func TestValidateAgentDefinition_RaiConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yaml string + wantErrSubst string + }{ + { + name: "valid rai_policy", + yaml: `kind: hosted +name: rai-agent +policies: + - type: rai_policy + rai_policy_name: /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/Microsoft.CognitiveServices/accounts/my-account/raiPolicies/Microsoft.DefaultV2 +protocols: + - protocol: responses + version: "1.0.0" +`, + }, + { + name: "rai_policy missing policy name", + yaml: `kind: hosted +name: rai-agent +policies: + - type: rai_policy +protocols: + - protocol: responses + version: "1.0.0" +`, + wantErrSubst: "policies[0] of type 'rai_policy' requires a policy name", + }, + { + name: "policy missing type", + yaml: `kind: hosted +name: rai-agent +policies: + - rai_policy_name: /subscriptions/x/raiPolicies/p +protocols: + - protocol: responses + version: "1.0.0" +`, + wantErrSubst: "policies[0] requires a type", + }, + { + name: "unsupported policy type", + yaml: `kind: hosted +name: rai-agent +policies: + - type: network_policy +protocols: + - protocol: responses + version: "1.0.0" +`, + wantErrSubst: "policies[0] has an unsupported type 'network_policy'", + }, + { + name: "no policies", + yaml: `kind: hosted +name: rai-agent +protocols: + - protocol: responses + version: "1.0.0" +`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := ValidateAgentDefinition([]byte(tc.yaml)) + if tc.wantErrSubst == "" { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErrSubst) + } + if !strings.Contains(err.Error(), tc.wantErrSubst) { + t.Fatalf("expected error containing %q, got %q", tc.wantErrSubst, err.Error()) + } + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/hosted-agent-with-rai.yaml b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/hosted-agent-with-rai.yaml new file mode 100644 index 00000000000..ccab564a4b9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/hosted-agent-with-rai.yaml @@ -0,0 +1,11 @@ +template: + kind: hosted + name: hosted-rai-agent + description: A hosted container agent with a content-safety guardrail policy + policies: + - type: rai_policy + # Full ARM resource ID of the RAI policy on the Cognitive Services account. + rai_policy_name: /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/Microsoft.CognitiveServices/accounts/my-account/raiPolicies/Microsoft.DefaultV2 + protocols: + - protocol: responses + version: "1.0.0" diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata_test.go index 569badd1e1b..7ab78d58717 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata_test.go @@ -31,6 +31,12 @@ func TestFixtures_ValidYAML(t *testing.T) { wantKind: AgentKindHosted, wantName: "hosted-test-agent", }, + { + name: "hosted agent with rai policy", + file: filepath.Join("testdata", "hosted-agent-with-rai.yaml"), + wantKind: AgentKindHosted, + wantName: "hosted-rai-agent", + }, } for _, tc := range tests { diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index 988e3294707..8038d30ba06 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -191,6 +191,24 @@ type CodeConfiguration struct { DependencyResolution *string `json:"dependencyResolution,omitempty" yaml:"dependency_resolution,omitempty"` } +// PolicyType identifies the kind of governance policy attached to a hosted agent. +type PolicyType string + +const ( + // PolicyTypeRai is a Responsible AI (content safety) policy. + PolicyTypeRai PolicyType = "rai_policy" +) + +// Policy represents a single safety or governance policy attached to a hosted agent. +// Type discriminates the policy kind; the remaining fields are interpreted based on Type. +// +// For Type "rai_policy", RaiPolicyName is the full ARM resource ID of the RAI policy, for example +// "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//raiPolicies/". +type Policy struct { + Type PolicyType `json:"type" yaml:"type"` + RaiPolicyName string `json:"raiPolicyName,omitempty" yaml:"rai_policy_name,omitempty"` +} + // ContainerAgent This represents a container based agent hosted by the provider/publisher. // The intent is to represent a container application that the user wants to run // in a hosted environment that the provider manages. @@ -210,6 +228,7 @@ type ContainerAgent struct { AgentEndpoint *AgentEndpoint `json:"agentEndpoint,omitempty" yaml:"agent_endpoint,omitempty"` AgentCard *AgentCard `json:"agentCard,omitempty" yaml:"agent_card,omitempty"` CodeConfiguration *CodeConfiguration `json:"codeConfiguration,omitempty" yaml:"code_configuration,omitempty"` + Policies []Policy `json:"policies,omitempty" yaml:"policies,omitempty"` } // AgentManifest The following represents a manifest that can be used to create agents dynamically.