From 6b1017d949cc40d203c07bb60d270db156dca26f Mon Sep 17 00:00:00 2001 From: amitbhave10 Date: Sat, 15 Aug 2026 05:15:00 +0000 Subject: [PATCH 1/6] feat(agents): add invocationsModeration to RAI policies Hosted agents on the invocations protocol could attach a RAI policy through azd but had no way to tell the content-safety proxy where the moderatable text lives in their request and response bodies. Without that the proxy has nothing to submit to the policy, so the guardrail is attached but inert. Add an optional `invocationsModeration` block to `rai_policy` policies covering responseMode, input/output content types, input/output JSONPaths, and SSE stream selectors, mapped onto `rai_config.invocations_moderation` on the wire. Validation mirrors the service's own create-time rules so misconfiguration is caught locally instead of surfacing as an opaque invalid_payload response, and declaring the block on an agent that does not expose the `invocations` protocol is rejected rather than silently deploying a policy that never runs. Agents that omit the block serialize exactly as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d91d7b94-da15-4dac-b0d4-195fad8fa01b --- .../extensions/azure.ai.agents/CHANGELOG.md | 6 + cli/azd/extensions/azure.ai.agents/README.md | 66 +++++++ .../internal/pkg/agents/agent_api/models.go | 48 +++++ .../internal/pkg/agents/agent_yaml/map.go | 32 ++- .../pkg/agents/agent_yaml/map_test.go | 149 ++++++++++++++ .../internal/pkg/agents/agent_yaml/parse.go | 122 ++++++++++++ .../pkg/agents/agent_yaml/parse_test.go | 187 ++++++++++++++++++ ...ted-agent-with-invocations-moderation.yaml | 26 +++ .../pkg/agents/agent_yaml/testdata_test.go | 6 + .../internal/pkg/agents/agent_yaml/yaml.go | 63 +++++- .../schemas/azure.ai.agent.json | 51 ++++- 11 files changed, 752 insertions(+), 4 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/hosted-agent-with-invocations-moderation.yaml diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index a638a6b6b50..dc8bde72c8b 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 +## 1.0.0-beta.11 (Unreleased) + +### Features Added + +- Add `invocationsModeration` to `rai_policy` policies so hosted agents on the `invocations` protocol can tell the content-safety proxy where the moderatable text lives in their request and response bodies. Supports buffered and server-sent-event responses via `responseMode`, `inputPaths`, `outputPaths`, and `streamSelectors`, and is validated locally before deploy. + ## 1.0.0-beta.10 (2026-08-13) ### Features Added diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index 80df72db3ac..452f4d7b2fa 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -133,6 +133,72 @@ Details: > the other inline agent properties such as `codeConfiguration` and > `environmentVariables`. +### Moderating invocations-protocol traffic + +For agents that expose the `invocations` protocol, the RAI policy alone is not +enough: the content-safety proxy needs to be told **where the text lives** in the +request and response bodies. Without that it has nothing to submit to the policy, +so no content is actually screened. Supply an `invocationsModeration` block on the +`rai_policy` entry: + +```yaml +services: + my-agent: + host: azure.ai.agent + project: . + kind: hosted + name: my-agent + protocols: + - protocol: invocations + version: "1.0.0" + policies: + - type: rai_policy + raiPolicyName: /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//raiPolicies/ + invocationsModeration: + responseMode: both + inputContentType: json + outputContentType: json + inputPaths: + - $.input + outputPaths: + - $.output + streamSelectors: + - eventType: response.output_text.delta + textField: $.delta +``` + +Fields: + +| Field | Required | Description | +| --- | --- | --- | +| `responseMode` | yes | `non_streaming`, `streaming`, or `both`. | +| `inputContentType` | no | `json` (default) or `text`. | +| `outputContentType` | no | `json` (default) or `text`. | +| `inputPaths` | when `inputContentType` is `json` | JSONPath expressions selecting the request text. | +| `outputPaths` | when `responseMode` includes non-streaming and `outputContentType` is `json` | JSONPath expressions selecting the buffered response text. | +| `streamSelectors` | when `responseMode` includes streaming and `outputContentType` is `json` | `eventType` (required) and `textField` per server-sent event frame. | + +`invocationsModeration` is only valid on agents whose `protocols` list includes +`invocations`. Declaring it elsewhere — including on an `invocations_ws`-only +agent, which does not go through the content-safety HTTP proxy — fails validation +rather than silently deploying a policy that never runs. + +> **Understanding `responseMode`:** it declares which response *shapes* the +> container can produce, **not** "input and output". Input is always moderated. +> For the output side the proxy inspects the actual response `Content-Type` and +> runs exactly one gate: the SSE gate for `text/event-stream`, the buffered gate +> otherwise. Use `both` only for containers that genuinely answer both ways — +> if a response arrives in a shape `responseMode` did not declare, the request +> fails closed rather than skipping moderation. + +Set `inputContentType`/`outputContentType` to `text` when the body is plain text; +the whole body is then moderated and no paths are needed for that direction. + +As with `raiPolicyName`, the deprecated on-disk `agent.yaml` shape uses snake_case +keys throughout this block (`invocations_moderation`, `response_mode`, +`input_paths`, `stream_selectors`, `event_type`, and so on). The **values** +(`non_streaming`, `streaming`, `both`, `json`, `text`) are the same in both. + ## Session idle timeout A hosted agent's runtime session sandbox is suspended by Foundry after a period diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index a276ef6559c..2e1362403a1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -90,9 +90,57 @@ const ( AgentEventHandlerDestinationTypeEvals AgentEventHandlerDestinationType = "evals" ) +// RaiInvocationContentType identifies how the invocations request or response body is encoded, +// which determines how the content-safety proxy extracts the text it moderates. +type RaiInvocationContentType string + +const ( + // RaiInvocationContentTypeJSON extracts text from a JSON body using JSONPath expressions. + RaiInvocationContentTypeJSON RaiInvocationContentType = "json" + // RaiInvocationContentTypeText treats the whole body as the text to moderate. + RaiInvocationContentTypeText RaiInvocationContentType = "text" +) + +// RaiInvocationMode declares the response shapes the agent container is able to produce. +// It is not an "input and output" switch: at runtime the proxy inspects the actual response +// Content-Type and runs exactly one output gate. +type RaiInvocationMode string + +const ( + // RaiInvocationModeNonStreaming declares the agent only returns buffered (non-SSE) responses. + RaiInvocationModeNonStreaming RaiInvocationMode = "non_streaming" + // RaiInvocationModeStreaming declares the agent only returns server-sent event streams. + RaiInvocationModeStreaming RaiInvocationMode = "streaming" + // RaiInvocationModeBoth declares the agent may return either shape depending on the request. + RaiInvocationModeBoth RaiInvocationMode = "both" +) + +// SseTextSelector locates the moderatable text inside a single server-sent event frame. +type SseTextSelector struct { + // EventType is the SSE event name the selector applies to. + EventType string `json:"event_type"` + // TextField is the JSONPath expression, relative to the frame payload, holding the text. + TextField string `json:"text_field,omitempty"` +} + +// InvocationsModeration configures how the content-safety proxy extracts text from +// invocations-protocol requests and responses so it can be submitted to the RAI policy. +// Without it a RAI policy attached to an invocations agent has nothing to moderate. +type InvocationsModeration struct { + InputContentType RaiInvocationContentType `json:"input_content_type,omitempty"` + OutputContentType RaiInvocationContentType `json:"output_content_type,omitempty"` + ResponseMode RaiInvocationMode `json:"response_mode,omitempty"` + InputPaths []string `json:"input_paths,omitempty"` + OutputPaths []string `json:"output_paths,omitempty"` + StreamSelectors []SseTextSelector `json:"stream_selectors,omitempty"` +} + // RaiConfig represents configuration for Responsible AI content filtering type RaiConfig struct { RaiPolicyName string `json:"rai_policy_name"` + // InvocationsModeration is optional and only meaningful for agents that expose the + // invocations protocol. + InvocationsModeration *InvocationsModeration `json:"invocations_moderation,omitempty"` } // AgentDefinition is the base definition for all agent types 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 8a385d27632..ea3e42f2870 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 @@ -8,6 +8,7 @@ import ( "maps" "math" "regexp" + "slices" "strings" "azureaiagent/internal/pkg/agents/agent_api" @@ -91,12 +92,41 @@ func constructBuildConfig(options ...AgentBuildOption) *AgentBuildConfig { 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 &agent_api.RaiConfig{ + RaiPolicyName: policy.RaiPolicyName, + InvocationsModeration: mapInvocationsModeration(policy.InvocationsModeration), + } } } return nil } +// mapInvocationsModeration translates the YAML invocations-moderation block into its +// data-plane representation. It returns nil when the block is absent so agents that do not +// configure it serialize exactly as before. +func mapInvocationsModeration(moderation *InvocationsModeration) *agent_api.InvocationsModeration { + if moderation == nil { + return nil + } + + mapped := &agent_api.InvocationsModeration{ + InputContentType: agent_api.RaiInvocationContentType(moderation.InputContentType), + OutputContentType: agent_api.RaiInvocationContentType(moderation.OutputContentType), + ResponseMode: agent_api.RaiInvocationMode(moderation.ResponseMode), + InputPaths: slices.Clone(moderation.InputPaths), + OutputPaths: slices.Clone(moderation.OutputPaths), + } + + for _, selector := range moderation.StreamSelectors { + mapped.StreamSelectors = append(mapped.StreamSelectors, agent_api.SseTextSelector{ + EventType: selector.EventType, + TextField: selector.TextField, + }) + } + + return mapped +} + // 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. 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 d500c6bb413..89ecd5d357e 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 @@ -6,6 +6,7 @@ package agent_yaml import ( "encoding/json" "math" + "slices" "strings" "testing" @@ -1730,6 +1731,154 @@ func TestMapRaiConfig(t *testing.T) { } } +func TestMapRaiConfig_WithInvocationsModeration(t *testing.T) { + t.Parallel() + + got := mapRaiConfig([]Policy{{ + Type: PolicyTypeRai, + RaiPolicyName: "p1", + InvocationsModeration: &InvocationsModeration{ + InputContentType: InvocationContentTypeJSON, + OutputContentType: InvocationContentTypeJSON, + ResponseMode: InvocationResponseModeBoth, + InputPaths: []string{"$.input"}, + OutputPaths: []string{"$.output"}, + StreamSelectors: []SseTextSelector{ + {EventType: "response.output_text.delta", TextField: "$.delta"}, + }, + }, + }}) + + if got == nil { + t.Fatal("mapRaiConfig returned nil") + } + moderation := got.InvocationsModeration + if moderation == nil { + t.Fatal("InvocationsModeration is nil") + } + if moderation.InputContentType != agent_api.RaiInvocationContentTypeJSON { + t.Errorf("InputContentType = %q, want %q", + moderation.InputContentType, agent_api.RaiInvocationContentTypeJSON) + } + if moderation.OutputContentType != agent_api.RaiInvocationContentTypeJSON { + t.Errorf("OutputContentType = %q, want %q", + moderation.OutputContentType, agent_api.RaiInvocationContentTypeJSON) + } + if moderation.ResponseMode != agent_api.RaiInvocationModeBoth { + t.Errorf("ResponseMode = %q, want %q", moderation.ResponseMode, agent_api.RaiInvocationModeBoth) + } + if !slices.Equal(moderation.InputPaths, []string{"$.input"}) { + t.Errorf("InputPaths = %v, want [$.input]", moderation.InputPaths) + } + if !slices.Equal(moderation.OutputPaths, []string{"$.output"}) { + t.Errorf("OutputPaths = %v, want [$.output]", moderation.OutputPaths) + } + if len(moderation.StreamSelectors) != 1 { + t.Fatalf("len(StreamSelectors) = %d, want 1", len(moderation.StreamSelectors)) + } + if moderation.StreamSelectors[0].EventType != "response.output_text.delta" { + t.Errorf("StreamSelectors[0].EventType = %q, want response.output_text.delta", + moderation.StreamSelectors[0].EventType) + } + if moderation.StreamSelectors[0].TextField != "$.delta" { + t.Errorf("StreamSelectors[0].TextField = %q, want $.delta", + moderation.StreamSelectors[0].TextField) + } +} + +func TestMapRaiConfig_WithoutInvocationsModeration(t *testing.T) { + t.Parallel() + + got := mapRaiConfig([]Policy{{Type: PolicyTypeRai, RaiPolicyName: "p1"}}) + if got == nil { + t.Fatal("mapRaiConfig returned nil") + } + if got.InvocationsModeration != nil { + t.Errorf("InvocationsModeration = %+v, want nil", got.InvocationsModeration) + } + + // Agents that do not configure moderation must serialize exactly as they did before the + // field existed, so existing deployments are unaffected. + encoded, err := json.Marshal(got) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(encoded) != `{"rai_policy_name":"p1"}` { + t.Errorf("serialized rai_config = %s, want {\"rai_policy_name\":\"p1\"}", encoded) + } +} + +func TestMapRaiConfig_InvocationsModerationSlicesAreCopied(t *testing.T) { + t.Parallel() + + inputPaths := []string{"$.input"} + policies := []Policy{{ + Type: PolicyTypeRai, + RaiPolicyName: "p1", + InvocationsModeration: &InvocationsModeration{ + ResponseMode: InvocationResponseModeNonStreaming, + InputPaths: inputPaths, + }, + }} + + got := mapRaiConfig(policies) + inputPaths[0] = "$.mutated" + + if got.InvocationsModeration.InputPaths[0] != "$.input" { + t.Errorf("mapped InputPaths aliases the source slice: got %q", + got.InvocationsModeration.InputPaths[0]) + } +} + +func TestCreateHostedAgentAPIRequest_WithInvocationsModeration(t *testing.T) { + t.Parallel() + + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindHosted, + Name: "rai-agent", + }, + Protocols: []ProtocolVersionRecord{{Protocol: InvocationsProtocol, Version: "1.0.0"}}, + Policies: []Policy{{ + Type: PolicyTypeRai, + RaiPolicyName: "/subscriptions/x/raiPolicies/p", + InvocationsModeration: &InvocationsModeration{ + ResponseMode: InvocationResponseModeNonStreaming, + InputPaths: []string{"$.input"}, + OutputPaths: []string{"$.output"}, + }, + }}, + } + + req, err := CreateHostedAgentAPIRequest(agent, &AgentBuildConfig{ImageURL: "img:latest"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + definition, ok := req.Definition.(agent_api.HostedAgentDefinition) + if !ok { + t.Fatalf("unexpected definition type %T", req.Definition) + } + if definition.RaiConfig == nil || definition.RaiConfig.InvocationsModeration == nil { + t.Fatalf("expected invocations moderation on the request, got %+v", definition.RaiConfig) + } + + encoded, err := json.Marshal(definition.RaiConfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, want := range []string{ + `"invocations_moderation"`, + `"response_mode":"non_streaming"`, + `"input_paths":["$.input"]`, + `"output_paths":["$.output"]`, + } { + if !strings.Contains(string(encoded), want) { + t.Errorf("serialized rai_config %s missing %s", encoded, want) + } + } +} + // --------------------------------------------------------------------------- // Session configuration (idle timeout) mapping // --------------------------------------------------------------------------- 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 d61fed8905e..e990b86f5fa 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 @@ -6,6 +6,7 @@ package agent_yaml import ( "fmt" "regexp" + "slices" "strings" "go.yaml.in/yaml/v3" @@ -401,6 +402,8 @@ func ValidateAgentDefinition(templateBytes []byte) error { "('raiPolicyName' in azure.yaml, 'rai_policy_name' in agent.yaml)", i, policy.Type)) } + errors = append(errors, + validateInvocationsModeration(i, policy.InvocationsModeration, agent.Protocols)...) case "": errors = append(errors, fmt.Sprintf( "policies[%d] requires a type", i)) @@ -474,3 +477,122 @@ func ValidateAgentName(name string) error { return nil } + +// validateInvocationsModeration checks a policy's invocations-moderation block against the +// same structural rules the Agents service applies at create time, so a misconfiguration is +// caught locally instead of surfacing later as an opaque 'invalid_payload' response. +// +// It deliberately does not compile the JSONPath expressions; malformed paths are still +// reported by the service. +func validateInvocationsModeration( + index int, + moderation *InvocationsModeration, + protocols []ProtocolVersionRecord, +) []string { + if moderation == nil { + return nil + } + + prefix := fmt.Sprintf("policies[%d] invocationsModeration", index) + var errors []string + + if !exposesInvocationsProtocol(protocols) { + errors = append(errors, fmt.Sprintf( + "%s is only supported for agents that expose the '%s' protocol; "+ + "add it to 'protocols' or remove the moderation block", + prefix, InvocationsProtocol)) + } + + inputContentType, err := resolveInvocationContentType(moderation.InputContentType) + if err != nil { + errors = append(errors, fmt.Sprintf("%s.inputContentType %v", prefix, err)) + } + + outputContentType, err := resolveInvocationContentType(moderation.OutputContentType) + if err != nil { + errors = append(errors, fmt.Sprintf("%s.outputContentType %v", prefix, err)) + } + + allowsNonStreaming, allowsStreaming, err := resolveInvocationResponseMode(moderation.ResponseMode) + if err != nil { + errors = append(errors, fmt.Sprintf("%s.responseMode %v", prefix, err)) + } + + if inputContentType == InvocationContentTypeJSON && len(moderation.InputPaths) == 0 { + errors = append(errors, fmt.Sprintf( + "%s.inputPaths is required when inputContentType is '%s'", + prefix, InvocationContentTypeJSON)) + } + + if allowsNonStreaming && outputContentType == InvocationContentTypeJSON && + len(moderation.OutputPaths) == 0 { + errors = append(errors, fmt.Sprintf( + "%s.outputPaths is required when responseMode includes non-streaming "+ + "and outputContentType is '%s'", + prefix, InvocationContentTypeJSON)) + } + + if allowsStreaming && outputContentType == InvocationContentTypeJSON && + len(moderation.StreamSelectors) == 0 { + errors = append(errors, fmt.Sprintf( + "%s.streamSelectors is required when responseMode includes streaming "+ + "and outputContentType is '%s'", + prefix, InvocationContentTypeJSON)) + } + + for i, selector := range moderation.StreamSelectors { + if strings.TrimSpace(selector.EventType) == "" { + errors = append(errors, fmt.Sprintf( + "%s.streamSelectors[%d].eventType is required and must be non-empty", prefix, i)) + } + } + + return errors +} + +// resolveInvocationContentType normalizes an optional content type, defaulting to JSON. +// An unrecognized value yields an empty type alongside the error so callers naturally skip +// the downstream rules that depend on it instead of reporting cascading failures. +func resolveInvocationContentType(value string) (string, error) { + switch value { + case "": + return InvocationContentTypeJSON, nil + case InvocationContentTypeJSON, InvocationContentTypeText: + return value, nil + default: + return "", fmt.Errorf("must be '%s' or '%s', got '%s'", + InvocationContentTypeJSON, InvocationContentTypeText, value) + } +} + +// resolveInvocationResponseMode reports which output gates a response mode arms. Mode "both" +// arms both, but the proxy still runs exactly one gate per response, chosen from the actual +// response Content-Type. +func resolveInvocationResponseMode(value string) (allowsNonStreaming bool, allowsStreaming bool, err error) { + switch value { + case "": + return false, false, fmt.Errorf("is required (one of '%s', '%s', '%s')", + InvocationResponseModeNonStreaming, + InvocationResponseModeStreaming, + InvocationResponseModeBoth) + case InvocationResponseModeNonStreaming: + return true, false, nil + case InvocationResponseModeStreaming: + return false, true, nil + case InvocationResponseModeBoth: + return true, true, nil + default: + return false, false, fmt.Errorf("must be one of '%s', '%s', '%s', got '%s'", + InvocationResponseModeNonStreaming, + InvocationResponseModeStreaming, + InvocationResponseModeBoth, + value) + } +} + +// exposesInvocationsProtocol reports whether the agent declares the HTTP invocations protocol. +func exposesInvocationsProtocol(protocols []ProtocolVersionRecord) bool { + return slices.ContainsFunc(protocols, func(record ProtocolVersionRecord) bool { + return record.Protocol == InvocationsProtocol + }) +} 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 6129008d05b..677d97a57ce 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 @@ -1186,3 +1186,190 @@ protocols: }) } } + +func TestValidateAgentDefinition_InvocationsModeration(t *testing.T) { + t.Parallel() + + // invocationsAgent wraps a moderation block in an otherwise-valid hosted agent that + // exposes the invocations protocol, so each case isolates the moderation rule under test. + invocationsAgent := func(moderation string) string { + return `kind: hosted +name: rai-agent +policies: + - type: rai_policy + rai_policy_name: /subscriptions/x/raiPolicies/p + invocations_moderation: +` + moderation + `protocols: + - protocol: invocations + version: "1.0.0" +` + } + + tests := []struct { + name string + yaml string + wantErrSubst string + }{ + { + name: "valid non_streaming json", + yaml: invocationsAgent(` response_mode: non_streaming + input_paths: ["$.input"] + output_paths: ["$.output"] +`), + }, + { + name: "valid streaming json", + yaml: invocationsAgent(` response_mode: streaming + input_paths: ["$.input"] + stream_selectors: + - event_type: response.output_text.delta + text_field: $.delta +`), + }, + { + name: "valid both requires output_paths and stream_selectors", + yaml: invocationsAgent(` response_mode: both + input_paths: ["$.input"] + output_paths: ["$.output"] + stream_selectors: + - event_type: response.output_text.delta + text_field: $.delta +`), + }, + { + name: "valid text content types need no paths", + yaml: invocationsAgent(` response_mode: both + input_content_type: text + output_content_type: text +`), + }, + { + name: "response_mode is required", + yaml: invocationsAgent(` input_paths: ["$.input"] +`), + wantErrSubst: "policies[0] invocationsModeration.responseMode is required", + }, + { + name: "response_mode must be a known value", + yaml: invocationsAgent(` response_mode: sometimes + input_paths: ["$.input"] +`), + wantErrSubst: "policies[0] invocationsModeration.responseMode must be one of", + }, + { + name: "input_content_type must be json or text", + yaml: invocationsAgent(` response_mode: non_streaming + input_content_type: xml + output_paths: ["$.output"] +`), + wantErrSubst: "policies[0] invocationsModeration.inputContentType must be 'json' or 'text'", + }, + { + name: "output_content_type must be json or text", + yaml: invocationsAgent(` response_mode: non_streaming + input_paths: ["$.input"] + output_content_type: xml +`), + wantErrSubst: "policies[0] invocationsModeration.outputContentType must be 'json' or 'text'", + }, + { + name: "input_paths required when input content type defaults to json", + yaml: invocationsAgent(` response_mode: non_streaming + output_paths: ["$.output"] +`), + wantErrSubst: "policies[0] invocationsModeration.inputPaths is required when inputContentType is 'json'", + }, + { + name: "output_paths required for non-streaming json", + yaml: invocationsAgent(` response_mode: non_streaming + input_paths: ["$.input"] +`), + wantErrSubst: "policies[0] invocationsModeration.outputPaths is required when responseMode " + + "includes non-streaming", + }, + { + name: "stream_selectors required for streaming json", + yaml: invocationsAgent(` response_mode: streaming + input_paths: ["$.input"] +`), + wantErrSubst: "policies[0] invocationsModeration.streamSelectors is required when responseMode " + + "includes streaming", + }, + { + name: "stream selector event_type must be non-empty", + yaml: invocationsAgent(` response_mode: streaming + input_paths: ["$.input"] + stream_selectors: + - text_field: $.delta +`), + wantErrSubst: "policies[0] invocationsModeration.streamSelectors[0].eventType is required", + }, + { + name: "rejected on an agent that does not expose the invocations protocol", + yaml: `kind: hosted +name: rai-agent +policies: + - type: rai_policy + rai_policy_name: /subscriptions/x/raiPolicies/p + invocations_moderation: + response_mode: non_streaming + input_paths: ["$.input"] + output_paths: ["$.output"] +protocols: + - protocol: responses + version: "1.0.0" +`, + wantErrSubst: "policies[0] invocationsModeration is only supported for agents that expose " + + "the 'invocations' protocol", + }, + { + name: "invocations_ws alone does not satisfy the protocol requirement", + yaml: `kind: hosted +name: rai-agent +policies: + - type: rai_policy + rai_policy_name: /subscriptions/x/raiPolicies/p + invocations_moderation: + response_mode: non_streaming + input_paths: ["$.input"] + output_paths: ["$.output"] +protocols: + - protocol: invocations_ws + version: "1.0.0" +`, + wantErrSubst: "policies[0] invocationsModeration is only supported for agents that expose " + + "the 'invocations' protocol", + }, + { + name: "omitting the block leaves an invocations agent valid", + yaml: `kind: hosted +name: rai-agent +policies: + - type: rai_policy + rai_policy_name: /subscriptions/x/raiPolicies/p +protocols: + - protocol: invocations + 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-invocations-moderation.yaml b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/hosted-agent-with-invocations-moderation.yaml new file mode 100644 index 00000000000..71151a7c5c9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/testdata/hosted-agent-with-invocations-moderation.yaml @@ -0,0 +1,26 @@ +template: + kind: hosted + name: hosted-invocations-moderation-agent + description: A hosted invocations agent whose RAI policy moderates request and response text + 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 + # Tells the content-safety proxy which text to moderate. Without this the policy is + # attached but has nothing to inspect on the invocations path. + invocations_moderation: + # This container answers both buffered and streaming requests. The proxy still runs + # exactly one output gate per response, chosen from the actual response Content-Type. + response_mode: both + input_content_type: json + output_content_type: json + input_paths: + - $.input + output_paths: + - $.output + stream_selectors: + - event_type: response.output_text.delta + text_field: $.delta + protocols: + - protocol: invocations + 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 7ab78d58717..dce5598ca7a 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 @@ -37,6 +37,12 @@ func TestFixtures_ValidYAML(t *testing.T) { wantKind: AgentKindHosted, wantName: "hosted-rai-agent", }, + { + name: "hosted agent with invocations moderation", + file: filepath.Join("testdata", "hosted-agent-with-invocations-moderation.yaml"), + wantKind: AgentKindHosted, + wantName: "hosted-invocations-moderation-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 fd70eb6aa45..34fc643b83d 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 @@ -267,14 +267,73 @@ const ( PolicyTypeRai PolicyType = "rai_policy" ) +// Invocation content types describe how a request or response body is encoded, which +// determines how the content-safety proxy extracts the text it moderates. Both default to +// InvocationContentTypeJSON when omitted. +// +// Keys in these structures follow the extension's dual-casing convention: camelCase in +// azure.yaml, snake_case in the deprecated on-disk agent.yaml. The values below are wire +// values and stay snake_case in both. +const ( + InvocationContentTypeJSON = "json" + InvocationContentTypeText = "text" +) + +// Invocation response modes declare which response shapes the agent container can produce. +const ( + InvocationResponseModeNonStreaming = "non_streaming" + InvocationResponseModeStreaming = "streaming" + InvocationResponseModeBoth = "both" +) + +// InvocationsProtocol is the protocol name an agent must expose for invocations moderation +// to have any effect. The WebSocket variant ("invocations_ws") does not go through the +// content-safety HTTP proxy and is therefore not covered. +const InvocationsProtocol = "invocations" + +// SseTextSelector locates the moderatable text inside a single server-sent event frame. +type SseTextSelector struct { + // EventType is the SSE event name this selector applies to. Required. + EventType string `json:"eventType,omitempty" yaml:"event_type,omitempty"` + // TextField is the JSONPath expression, relative to the frame payload, holding the text. + TextField string `json:"textField,omitempty" yaml:"text_field,omitempty"` +} + +// InvocationsModeration configures how the content-safety proxy extracts the text it submits +// to the RAI policy for agents that expose the invocations protocol. A RAI policy without it +// has nothing to moderate on the invocations path. +// +// ResponseMode declares the response shapes the container can produce; it is not an +// "input and output" switch. At runtime the proxy picks exactly one output gate from the +// actual response Content-Type. +type InvocationsModeration struct { + // InputContentType is "json" or "text". Defaults to "json" when omitted. + InputContentType string `json:"inputContentType,omitempty" yaml:"input_content_type,omitempty"` + // OutputContentType is "json" or "text". Defaults to "json" when omitted. + OutputContentType string `json:"outputContentType,omitempty" yaml:"output_content_type,omitempty"` + // ResponseMode is "non_streaming", "streaming" or "both". Required. + ResponseMode string `json:"responseMode,omitempty" yaml:"response_mode,omitempty"` + // InputPaths are JSONPath expressions selecting request text. Required when the input + // content type resolves to "json". + InputPaths []string `json:"inputPaths,omitempty" yaml:"input_paths,omitempty"` + // OutputPaths are JSONPath expressions selecting buffered response text. Required when + // ResponseMode includes non-streaming and the output content type resolves to "json". + OutputPaths []string `json:"outputPaths,omitempty" yaml:"output_paths,omitempty"` + // StreamSelectors locate text within SSE frames. Required when ResponseMode includes + // streaming and the output content type resolves to "json". + StreamSelectors []SseTextSelector `json:"streamSelectors,omitempty" yaml:"stream_selectors,omitempty"` +} + // 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/". +// InvocationsModeration is optional and only valid for agents exposing the invocations protocol. type Policy struct { - Type PolicyType `json:"type" yaml:"type"` - RaiPolicyName string `json:"raiPolicyName,omitempty" yaml:"rai_policy_name,omitempty"` + Type PolicyType `json:"type" yaml:"type"` + RaiPolicyName string `json:"raiPolicyName,omitempty" yaml:"rai_policy_name,omitempty"` + InvocationsModeration *InvocationsModeration `json:"invocationsModeration,omitempty" yaml:"invocations_moderation,omitempty"` } // ContainerAgent This represents a container based agent hosted by the provider/publisher. diff --git a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json index e486a47de8b..d6d76ed6345 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json @@ -181,11 +181,60 @@ "description": "A safety or governance policy attached to the agent.", "properties": { "type": { "type": "string", "description": "Policy type (e.g., 'rai_policy')." }, - "raiPolicyName": { "type": "string", "description": "ARM resource ID of the RAI policy (for type 'rai_policy')." } + "raiPolicyName": { "type": "string", "description": "ARM resource ID of the RAI policy (for type 'rai_policy')." }, + "invocationsModeration": { "$ref": "#/definitions/InvocationsModeration" } }, "required": ["type"], "additionalProperties": false }, + "InvocationsModeration": { + "type": "object", + "description": "Configures how the content-safety proxy extracts the text it submits to the RAI policy. Only supported for agents that expose the 'invocations' protocol; without it an attached RAI policy has nothing to moderate on that path.", + "properties": { + "inputContentType": { + "type": "string", + "enum": ["json", "text"], + "description": "How the request body is encoded. Defaults to 'json'." + }, + "outputContentType": { + "type": "string", + "enum": ["json", "text"], + "description": "How the response body is encoded. Defaults to 'json'." + }, + "responseMode": { + "type": "string", + "enum": ["non_streaming", "streaming", "both"], + "description": "Response shapes the agent container can produce. This declares a capability, not an input/output switch: the proxy runs exactly one output gate per response, chosen from the actual response Content-Type." + }, + "inputPaths": { + "type": "array", + "description": "JSONPath expressions selecting request text. Required when inputContentType is 'json'.", + "items": { "type": "string" } + }, + "outputPaths": { + "type": "array", + "description": "JSONPath expressions selecting buffered response text. Required when responseMode includes non-streaming and outputContentType is 'json'.", + "items": { "type": "string" } + }, + "streamSelectors": { + "type": "array", + "description": "Locates text within server-sent event frames. Required when responseMode includes streaming and outputContentType is 'json'.", + "items": { "$ref": "#/definitions/SseTextSelector" } + } + }, + "required": ["responseMode"], + "additionalProperties": false + }, + "SseTextSelector": { + "type": "object", + "description": "Locates the moderatable text inside a single server-sent event frame.", + "properties": { + "eventType": { "type": "string", "description": "SSE event name this selector applies to." }, + "textField": { "type": "string", "description": "JSONPath expression, relative to the frame payload, holding the text." } + }, + "required": ["eventType"], + "additionalProperties": false + }, "ContainerSettings": { "type": "object", "description": "Container configuration for the Azure AI Agent Service target", From 8994694cf9bc31de33ca44f15ebb5bb9b59a6f28 Mon Sep 17 00:00:00 2001 From: amitbhave10 Date: Sat, 15 Aug 2026 05:27:39 +0000 Subject: [PATCH 2/6] fix(agents): address multi-model review of invocationsModeration - Report the invocations-protocol error alone instead of cascading field-level errors that are irrelevant on a non-invocations agent. - Encode the conditional-required rules in the JSON schema via if/then, so editors match azd's validation instead of falsely reporting an incomplete block as valid. Adds minItems and a non-blank eventType pattern. - Drop omitempty from the required response_mode / eventType fields so the wire and YAML models state the contract accurately. - Cover the inline azure.yaml (camelCase) path end to end, which the previous tests reached only through the snake_case agent.yaml surface. - Add the missing validation cases: `both` missing either output array, mixed text/json directions, explicit json, whitespace-only eventType, per-policy error indexing, and non-cascade assertions. - Document that inputPaths is required when inputContentType is omitted, and note the new protocol validation error in the changelog. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d91d7b94-da15-4dac-b0d4-195fad8fa01b --- .../extensions/azure.ai.agents/CHANGELOG.md | 2 +- cli/azd/extensions/azure.ai.agents/README.md | 2 +- .../internal/pkg/agents/agent_api/models.go | 2 +- .../internal/pkg/agents/agent_yaml/parse.go | 10 +- .../pkg/agents/agent_yaml/parse_test.go | 96 ++++++++++- .../internal/pkg/agents/agent_yaml/yaml.go | 2 +- .../internal/project/agent_policies_test.go | 163 ++++++++++++++++++ .../schemas/azure.ai.agent.json | 49 +++++- 8 files changed, 313 insertions(+), 13 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index dc8bde72c8b..60680a41823 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 -- Add `invocationsModeration` to `rai_policy` policies so hosted agents on the `invocations` protocol can tell the content-safety proxy where the moderatable text lives in their request and response bodies. Supports buffered and server-sent-event responses via `responseMode`, `inputPaths`, `outputPaths`, and `streamSelectors`, and is validated locally before deploy. +- Add `invocationsModeration` to `rai_policy` policies so hosted agents on the `invocations` protocol can tell the content-safety proxy where the moderatable text lives in their request and response bodies. Supports buffered and server-sent-event responses via `responseMode`, `inputPaths`, `outputPaths`, and `streamSelectors`, and is validated locally before deploy. Declaring `invocationsModeration` on an agent that does not expose the `invocations` protocol is now a validation error, since the block would otherwise be silently ignored. ## 1.0.0-beta.10 (2026-08-13) diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index 452f4d7b2fa..3631e2557bb 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -174,7 +174,7 @@ Fields: | `responseMode` | yes | `non_streaming`, `streaming`, or `both`. | | `inputContentType` | no | `json` (default) or `text`. | | `outputContentType` | no | `json` (default) or `text`. | -| `inputPaths` | when `inputContentType` is `json` | JSONPath expressions selecting the request text. | +| `inputPaths` | when `inputContentType` is `json` or omitted (it defaults to `json`) | JSONPath expressions selecting the request text. | | `outputPaths` | when `responseMode` includes non-streaming and `outputContentType` is `json` | JSONPath expressions selecting the buffered response text. | | `streamSelectors` | when `responseMode` includes streaming and `outputContentType` is `json` | `eventType` (required) and `textField` per server-sent event frame. | diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index 2e1362403a1..f1c563a4ae1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -129,7 +129,7 @@ type SseTextSelector struct { type InvocationsModeration struct { InputContentType RaiInvocationContentType `json:"input_content_type,omitempty"` OutputContentType RaiInvocationContentType `json:"output_content_type,omitempty"` - ResponseMode RaiInvocationMode `json:"response_mode,omitempty"` + ResponseMode RaiInvocationMode `json:"response_mode"` InputPaths []string `json:"input_paths,omitempty"` OutputPaths []string `json:"output_paths,omitempty"` StreamSelectors []SseTextSelector `json:"stream_selectors,omitempty"` 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 e990b86f5fa..4e692847c88 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 @@ -496,11 +496,13 @@ func validateInvocationsModeration( prefix := fmt.Sprintf("policies[%d] invocationsModeration", index) var errors []string + // The rest of the block is meaningless on an agent that never serves the invocations + // path, so report only the root cause rather than cascading field-level errors. if !exposesInvocationsProtocol(protocols) { - errors = append(errors, fmt.Sprintf( + return []string{fmt.Sprintf( "%s is only supported for agents that expose the '%s' protocol; "+ "add it to 'protocols' or remove the moderation block", - prefix, InvocationsProtocol)) + prefix, InvocationsProtocol)} } inputContentType, err := resolveInvocationContentType(moderation.InputContentType) @@ -552,7 +554,9 @@ func validateInvocationsModeration( // resolveInvocationContentType normalizes an optional content type, defaulting to JSON. // An unrecognized value yields an empty type alongside the error so callers naturally skip -// the downstream rules that depend on it instead of reporting cascading failures. +// the downstream rules that depend on it instead of reporting cascading failures. Suppressing +// those rules is deliberate: the corrected value determines whether paths are required at all, +// so guessing one here would risk demanding paths a 'text' agent never needs. func resolveInvocationContentType(value string) (string, error) { switch value { case "": 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 677d97a57ce..62b2082bd8b 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 @@ -1192,7 +1192,12 @@ func TestValidateAgentDefinition_InvocationsModeration(t *testing.T) { // invocationsAgent wraps a moderation block in an otherwise-valid hosted agent that // exposes the invocations protocol, so each case isolates the moderation rule under test. + // The caller's fragment must end with a newline; the guard below keeps a missing one from + // silently nesting `protocols` under the moderation block and skewing every assertion. invocationsAgent := func(moderation string) string { + if !strings.HasSuffix(moderation, "\n") { + t.Fatalf("moderation fragment must end with a newline, got %q", moderation) + } return `kind: hosted name: rai-agent policies: @@ -1206,9 +1211,14 @@ policies: } tests := []struct { - name string - yaml string + name string + yaml string + // wantErrSubst is the substring the validation error must contain, or "" when the + // definition is expected to validate cleanly. wantErrSubst string + // notWantErrSubst, when set, must NOT appear in the error. It locks in the + // deliberate suppression of cascading follow-on errors. + notWantErrSubst string }{ { name: "valid non_streaming json", @@ -1263,6 +1273,9 @@ policies: output_paths: ["$.output"] `), wantErrSubst: "policies[0] invocationsModeration.inputContentType must be 'json' or 'text'", + // An unusable content type must not also demand inputPaths: the corrected value + // decides whether paths are needed at all. + notWantErrSubst: "inputPaths is required", }, { name: "output_content_type must be json or text", @@ -1321,6 +1334,82 @@ protocols: `, wantErrSubst: "policies[0] invocationsModeration is only supported for agents that expose " + "the 'invocations' protocol", + // The rest of the block is irrelevant on a non-invocations agent, so the protocol + // error must be reported alone rather than buried under field-level noise. + notWantErrSubst: "responseMode", + }, + { + name: "both requires stream_selectors as well as output_paths", + yaml: invocationsAgent(` response_mode: both + input_paths: ["$.input"] + output_paths: ["$.output"] +`), + wantErrSubst: "policies[0] invocationsModeration.streamSelectors is required when responseMode " + + "includes streaming", + }, + { + name: "both requires output_paths as well as stream_selectors", + yaml: invocationsAgent(` response_mode: both + input_paths: ["$.input"] + stream_selectors: + - event_type: response.output_text.delta +`), + wantErrSubst: "policies[0] invocationsModeration.outputPaths is required when responseMode " + + "includes non-streaming", + }, + { + name: "text output needs neither output_paths nor stream_selectors", + yaml: invocationsAgent(` response_mode: both + output_content_type: text + input_paths: ["$.input"] +`), + }, + { + name: "text input needs no input_paths but json output still needs its own", + yaml: invocationsAgent(` response_mode: non_streaming + input_content_type: text + output_paths: ["$.output"] +`), + }, + { + name: "explicit json content types behave like the defaults", + yaml: invocationsAgent(` response_mode: non_streaming + input_content_type: json + output_content_type: json + input_paths: ["$.input"] + output_paths: ["$.output"] +`), + }, + { + name: "stream selector event_type may not be whitespace only", + yaml: invocationsAgent(` response_mode: streaming + input_paths: ["$.input"] + stream_selectors: + - event_type: " " +`), + wantErrSubst: "policies[0] invocationsModeration.streamSelectors[0].eventType is required", + }, + { + name: "each policy is validated under its own index", + yaml: `kind: hosted +name: rai-agent +policies: + - type: rai_policy + rai_policy_name: /subscriptions/x/raiPolicies/first + invocations_moderation: + response_mode: non_streaming + input_paths: ["$.input"] + output_paths: ["$.output"] + - type: rai_policy + rai_policy_name: /subscriptions/x/raiPolicies/second + invocations_moderation: + input_paths: ["$.input"] + output_paths: ["$.output"] +protocols: + - protocol: invocations + version: "1.0.0" +`, + wantErrSubst: "policies[1] invocationsModeration.responseMode is required", }, { name: "invocations_ws alone does not satisfy the protocol requirement", @@ -1370,6 +1459,9 @@ protocols: if !strings.Contains(err.Error(), tc.wantErrSubst) { t.Fatalf("expected error containing %q, got %q", tc.wantErrSubst, err.Error()) } + if tc.notWantErrSubst != "" && strings.Contains(err.Error(), tc.notWantErrSubst) { + t.Fatalf("expected error NOT to contain %q, got %q", tc.notWantErrSubst, err.Error()) + } }) } } 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 34fc643b83d..c892eda38d1 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 @@ -294,7 +294,7 @@ const InvocationsProtocol = "invocations" // SseTextSelector locates the moderatable text inside a single server-sent event frame. type SseTextSelector struct { // EventType is the SSE event name this selector applies to. Required. - EventType string `json:"eventType,omitempty" yaml:"event_type,omitempty"` + EventType string `json:"eventType" yaml:"event_type"` // TextField is the JSONPath expression, relative to the frame payload, holding the text. TextField string `json:"textField,omitempty" yaml:"text_field,omitempty"` } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go index 5b60bd73513..f619a5a4725 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go @@ -199,3 +199,166 @@ func TestAgentPoliciesValidation(t *testing.T) { }) } } + +// sampleInvocationsModeration is a fully-populated moderation block covering both +// the buffered and streaming output paths. +func sampleInvocationsModeration() *agent_yaml.InvocationsModeration { + return &agent_yaml.InvocationsModeration{ + InputContentType: agent_yaml.InvocationContentTypeJSON, + OutputContentType: agent_yaml.InvocationContentTypeJSON, + ResponseMode: agent_yaml.InvocationResponseModeBoth, + InputPaths: []string{"$.input"}, + OutputPaths: []string{"$.output"}, + StreamSelectors: []agent_yaml.SseTextSelector{ + {EventType: "response.output_text.delta", TextField: "$.delta"}, + }, + } +} + +// TestAgentPoliciesInvocationsModerationRoundTrip verifies the nested moderation +// block survives the inline azure.yaml service-property marshal and back, and is +// persisted under camelCase keys like the rest of the unified azure.yaml shape. +func TestAgentPoliciesInvocationsModerationRoundTrip(t *testing.T) { + t.Parallel() + + ca := sampleContainerAgent() + ca.Protocols = []agent_yaml.ProtocolVersionRecord{ + {Protocol: agent_yaml.InvocationsProtocol, Version: "1.0.0"}, + } + ca.Policies = []agent_yaml.Policy{ + { + Type: agent_yaml.PolicyTypeRai, + RaiPolicyName: raiPolicyID, + InvocationsModeration: sampleInvocationsModeration(), + }, + } + + props, err := AgentDefinitionToServiceProperties(ca, nil) + require.NoError(t, err) + + policy := props.GetFields()["policies"].GetListValue().GetValues()[0].GetStructValue().GetFields() + moderation := policy["invocationsModeration"].GetStructValue().GetFields() + require.NotEmpty(t, moderation, "invocationsModeration must survive the inline marshal") + require.Equal(t, "both", moderation["responseMode"].GetStringValue()) + require.NotContains(t, moderation, "response_mode", + "azure.yaml uses camelCase keys") + + selector := moderation["streamSelectors"].GetListValue().GetValues()[0].GetStructValue().GetFields() + require.Equal(t, "response.output_text.delta", selector["eventType"].GetStringValue()) + require.NotContains(t, selector, "event_type") + + svc := &azdext.ServiceConfig{ + Name: "rai-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + + got, isHosted, found, _, err := AgentDefinitionFromService(svc) + require.NoError(t, err) + require.True(t, found) + require.True(t, isHosted) + require.Equal(t, ca.Policies, got.Policies) +} + +// TestAgentPoliciesInvocationsModerationReachesRaiConfig is the end-to-end check +// that a moderation block authored inline in azure.yaml reaches the Foundry data +// plane as snake_case `rai_config.invocations_moderation`. +func TestAgentPoliciesInvocationsModerationReachesRaiConfig(t *testing.T) { + t.Parallel() + + agentDef, isHosted, found, _, err := AgentDefinitionFromService(inlineAgentService(t, map[string]any{ + "kind": "hosted", + "name": "rai-agent", + "protocols": []any{map[string]any{"protocol": "invocations", "version": "1.0.0"}}, + "policies": []any{ + map[string]any{ + "type": "rai_policy", + "raiPolicyName": raiPolicyID, + "invocationsModeration": map[string]any{ + "responseMode": "non_streaming", + "inputPaths": []any{"$.input"}, + "outputPaths": []any{"$.output"}, + }, + }, + }, + })) + require.NoError(t, err) + require.True(t, found) + require.True(t, isHosted) + + request, err := agent_yaml.CreateAgentAPIRequestFromDefinition( + agentDef, agent_yaml.WithImageURL("myregistry.azurecr.io/img:v1")) + require.NoError(t, err) + + definition, ok := request.Definition.(agent_api.HostedAgentDefinition) + require.True(t, ok) + require.NotNil(t, definition.RaiConfig) + require.NotNil(t, definition.RaiConfig.InvocationsModeration) + require.Equal(t, raiPolicyID, definition.RaiConfig.RaiPolicyName) + require.Equal(t, "non_streaming", string(definition.RaiConfig.InvocationsModeration.ResponseMode)) + require.Equal(t, []string{"$.input"}, definition.RaiConfig.InvocationsModeration.InputPaths) + require.Equal(t, []string{"$.output"}, definition.RaiConfig.InvocationsModeration.OutputPaths) +} + +// TestAgentPoliciesInvocationsModerationInlineValidation verifies the new +// validation rules fire for blocks authored inline in azure.yaml, not just for +// the deprecated on-disk agent.yaml. +func TestAgentPoliciesInvocationsModerationInlineValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + protocols []any + moderation map[string]any + wantErrSubst string + }{ + { + name: "protocol not exposed", + protocols: []any{map[string]any{"protocol": "responses", "version": "1.0.0"}}, + moderation: map[string]any{ + "responseMode": "non_streaming", + "inputPaths": []any{"$.input"}, + "outputPaths": []any{"$.output"}, + }, + wantErrSubst: "only supported for agents that expose the 'invocations' protocol", + }, + { + name: "missing response mode", + protocols: []any{map[string]any{"protocol": "invocations", "version": "1.0.0"}}, + moderation: map[string]any{ + "inputPaths": []any{"$.input"}, + "outputPaths": []any{"$.output"}, + }, + wantErrSubst: "policies[0] invocationsModeration.responseMode", + }, + { + name: "missing stream selectors", + protocols: []any{map[string]any{"protocol": "invocations", "version": "1.0.0"}}, + moderation: map[string]any{ + "responseMode": "streaming", + "inputPaths": []any{"$.input"}, + }, + wantErrSubst: "streamSelectors is required", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + _, _, _, _, err := AgentDefinitionFromService(inlineAgentService(t, map[string]any{ + "kind": "hosted", + "name": "rai-agent", + "protocols": test.protocols, + "policies": []any{ + map[string]any{ + "type": "rai_policy", + "raiPolicyName": raiPolicyID, + "invocationsModeration": test.moderation, + }, + }, + })) + require.ErrorContains(t, err, test.wantErrSubst) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json index d6d76ed6345..bee832ed1e4 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json @@ -208,28 +208,69 @@ }, "inputPaths": { "type": "array", - "description": "JSONPath expressions selecting request text. Required when inputContentType is 'json'.", + "description": "JSONPath expressions selecting request text. Required when inputContentType is 'json' or omitted.", + "minItems": 1, "items": { "type": "string" } }, "outputPaths": { "type": "array", - "description": "JSONPath expressions selecting buffered response text. Required when responseMode includes non-streaming and outputContentType is 'json'.", + "description": "JSONPath expressions selecting buffered response text. Required when responseMode includes non-streaming and outputContentType is 'json' or omitted.", + "minItems": 1, "items": { "type": "string" } }, "streamSelectors": { "type": "array", - "description": "Locates text within server-sent event frames. Required when responseMode includes streaming and outputContentType is 'json'.", + "description": "Locates text within server-sent event frames. Required when responseMode includes streaming and outputContentType is 'json' or omitted.", + "minItems": 1, "items": { "$ref": "#/definitions/SseTextSelector" } } }, "required": ["responseMode"], + "allOf": [ + { + "if": { "$ref": "#/definitions/InvocationsInputIsJson" }, + "then": { "required": ["inputPaths"] } + }, + { + "if": { + "allOf": [ + { "properties": { "responseMode": { "enum": ["non_streaming", "both"] } }, "required": ["responseMode"] }, + { "$ref": "#/definitions/InvocationsOutputIsJson" } + ] + }, + "then": { "required": ["outputPaths"] } + }, + { + "if": { + "allOf": [ + { "properties": { "responseMode": { "enum": ["streaming", "both"] } }, "required": ["responseMode"] }, + { "$ref": "#/definitions/InvocationsOutputIsJson" } + ] + }, + "then": { "required": ["streamSelectors"] } + } + ], "additionalProperties": false }, + "InvocationsInputIsJson": { + "description": "Matches when inputContentType resolves to 'json', i.e. it is set to 'json' or omitted entirely (the default).", + "anyOf": [ + { "properties": { "inputContentType": { "const": "json" } }, "required": ["inputContentType"] }, + { "not": { "required": ["inputContentType"] } } + ] + }, + "InvocationsOutputIsJson": { + "description": "Matches when outputContentType resolves to 'json', i.e. it is set to 'json' or omitted entirely (the default).", + "anyOf": [ + { "properties": { "outputContentType": { "const": "json" } }, "required": ["outputContentType"] }, + { "not": { "required": ["outputContentType"] } } + ] + }, "SseTextSelector": { "type": "object", "description": "Locates the moderatable text inside a single server-sent event frame.", "properties": { - "eventType": { "type": "string", "description": "SSE event name this selector applies to." }, + "eventType": { "type": "string", "pattern": "\\S", "description": "SSE event name this selector applies to." }, "textField": { "type": "string", "description": "JSONPath expression, relative to the frame payload, holding the text." } }, "required": ["eventType"], From 0f49c7dd08d08393d0c8f56d85585ccb34900306 Mon Sep 17 00:00:00 2001 From: amitbhave10 Date: Sat, 15 Aug 2026 05:28:46 +0000 Subject: [PATCH 3/6] docs(agents): link the changelog entry to PR #9596 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d91d7b94-da15-4dac-b0d4-195fad8fa01b --- cli/azd/extensions/azure.ai.agents/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 60680a41823..25e164202a6 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 -- Add `invocationsModeration` to `rai_policy` policies so hosted agents on the `invocations` protocol can tell the content-safety proxy where the moderatable text lives in their request and response bodies. Supports buffered and server-sent-event responses via `responseMode`, `inputPaths`, `outputPaths`, and `streamSelectors`, and is validated locally before deploy. Declaring `invocationsModeration` on an agent that does not expose the `invocations` protocol is now a validation error, since the block would otherwise be silently ignored. +- [[#9596]](https://github.com/Azure/azure-dev/pull/9596) Add `invocationsModeration` to `rai_policy` policies so hosted agents on the `invocations` protocol can tell the content-safety proxy where the moderatable text lives in their request and response bodies. Supports buffered and server-sent-event responses via `responseMode`, `inputPaths`, `outputPaths`, and `streamSelectors`, and is validated locally before deploy. Declaring `invocationsModeration` on an agent that does not expose the `invocations` protocol is now a validation error, since the block would otherwise be silently ignored. ## 1.0.0-beta.10 (2026-08-13) From a5f912614c160a376137a9d88b27effd3f655563 Mon Sep 17 00:00:00 2001 From: amitbhave10 Date: Sat, 15 Aug 2026 05:31:15 +0000 Subject: [PATCH 4/6] fix(agents): reword to satisfy the repo spell check "moderatable" is not in the cspell dictionary; use "the text to moderate" instead of extending the shared word list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d91d7b94-da15-4dac-b0d4-195fad8fa01b --- cli/azd/extensions/azure.ai.agents/CHANGELOG.md | 2 +- .../azure.ai.agents/internal/pkg/agents/agent_api/models.go | 2 +- .../azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go | 2 +- cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 25e164202a6..d36e32abfd9 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 -- [[#9596]](https://github.com/Azure/azure-dev/pull/9596) Add `invocationsModeration` to `rai_policy` policies so hosted agents on the `invocations` protocol can tell the content-safety proxy where the moderatable text lives in their request and response bodies. Supports buffered and server-sent-event responses via `responseMode`, `inputPaths`, `outputPaths`, and `streamSelectors`, and is validated locally before deploy. Declaring `invocationsModeration` on an agent that does not expose the `invocations` protocol is now a validation error, since the block would otherwise be silently ignored. +- [[#9596]](https://github.com/Azure/azure-dev/pull/9596) Add `invocationsModeration` to `rai_policy` policies so hosted agents on the `invocations` protocol can tell the content-safety proxy where the text to moderate lives in their request and response bodies. Supports buffered and server-sent-event responses via `responseMode`, `inputPaths`, `outputPaths`, and `streamSelectors`, and is validated locally before deploy. Declaring `invocationsModeration` on an agent that does not expose the `invocations` protocol is now a validation error, since the block would otherwise be silently ignored. ## 1.0.0-beta.10 (2026-08-13) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index f1c563a4ae1..549f9610f12 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -115,7 +115,7 @@ const ( RaiInvocationModeBoth RaiInvocationMode = "both" ) -// SseTextSelector locates the moderatable text inside a single server-sent event frame. +// SseTextSelector locates the text to moderate inside a single server-sent event frame. type SseTextSelector struct { // EventType is the SSE event name the selector applies to. EventType string `json:"event_type"` 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 c892eda38d1..46fffca9e6a 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 @@ -291,7 +291,7 @@ const ( // content-safety HTTP proxy and is therefore not covered. const InvocationsProtocol = "invocations" -// SseTextSelector locates the moderatable text inside a single server-sent event frame. +// SseTextSelector locates the text to moderate inside a single server-sent event frame. type SseTextSelector struct { // EventType is the SSE event name this selector applies to. Required. EventType string `json:"eventType" yaml:"event_type"` diff --git a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json index bee832ed1e4..55a8ea2c55d 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json @@ -268,7 +268,7 @@ }, "SseTextSelector": { "type": "object", - "description": "Locates the moderatable text inside a single server-sent event frame.", + "description": "Locates the text to moderate inside a single server-sent event frame.", "properties": { "eventType": { "type": "string", "pattern": "\\S", "description": "SSE event name this selector applies to." }, "textField": { "type": "string", "description": "JSONPath expression, relative to the frame payload, holding the text." } From 594b685b93db554876b87584a0b8f498f1fc3e43 Mon Sep 17 00:00:00 2001 From: amitbhave10 Date: Sat, 15 Aug 2026 12:43:54 +0000 Subject: [PATCH 5/6] fix(agents): address Copilot review on invocationsModeration - Reject invocationsModeration on non-hosted agent kinds. Only hosted agents carry policies to the service, so a block on a prompt-voice or workflow definition parsed cleanly and was dropped silently -- even with an invalid response_mode -- which contradicted the documented fail-fast guarantee. Validated from a minimal policies envelope, since the other kinds have no policies field of their own. - Drop the CHANGELOG entry. Per AGENTS.md "Release preparation", release sections are added by the dedicated version-bump PR that touches only version.txt, extension.yaml and CHANGELOG.md; feature PRs leave it alone to avoid merge-conflict churn. - State the json-or-omitted defaulting in the outputPaths and streamSelectors rows, matching the inputPaths row, and note the hosted requirement alongside the protocol requirement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d91d7b94-da15-4dac-b0d4-195fad8fa01b --- .../extensions/azure.ai.agents/CHANGELOG.md | 6 -- cli/azd/extensions/azure.ai.agents/README.md | 12 +-- .../internal/pkg/agents/agent_yaml/parse.go | 31 +++++++ .../pkg/agents/agent_yaml/parse_test.go | 90 +++++++++++++++++++ 4 files changed, 127 insertions(+), 12 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index d36e32abfd9..a638a6b6b50 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,11 +1,5 @@ # Release History -## 1.0.0-beta.11 (Unreleased) - -### Features Added - -- [[#9596]](https://github.com/Azure/azure-dev/pull/9596) Add `invocationsModeration` to `rai_policy` policies so hosted agents on the `invocations` protocol can tell the content-safety proxy where the text to moderate lives in their request and response bodies. Supports buffered and server-sent-event responses via `responseMode`, `inputPaths`, `outputPaths`, and `streamSelectors`, and is validated locally before deploy. Declaring `invocationsModeration` on an agent that does not expose the `invocations` protocol is now a validation error, since the block would otherwise be silently ignored. - ## 1.0.0-beta.10 (2026-08-13) ### Features Added diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index 3631e2557bb..4ddac596f9a 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -175,13 +175,13 @@ Fields: | `inputContentType` | no | `json` (default) or `text`. | | `outputContentType` | no | `json` (default) or `text`. | | `inputPaths` | when `inputContentType` is `json` or omitted (it defaults to `json`) | JSONPath expressions selecting the request text. | -| `outputPaths` | when `responseMode` includes non-streaming and `outputContentType` is `json` | JSONPath expressions selecting the buffered response text. | -| `streamSelectors` | when `responseMode` includes streaming and `outputContentType` is `json` | `eventType` (required) and `textField` per server-sent event frame. | +| `outputPaths` | when `responseMode` includes non-streaming and `outputContentType` is `json` or omitted (it defaults to `json`) | JSONPath expressions selecting the buffered response text. | +| `streamSelectors` | when `responseMode` includes streaming and `outputContentType` is `json` or omitted (it defaults to `json`) | `eventType` (required) and `textField` per server-sent event frame. | -`invocationsModeration` is only valid on agents whose `protocols` list includes -`invocations`. Declaring it elsewhere — including on an `invocations_ws`-only -agent, which does not go through the content-safety HTTP proxy — fails validation -rather than silently deploying a policy that never runs. +`invocationsModeration` is only valid on a `hosted` agent whose `protocols` list +includes `invocations`. Declaring it elsewhere — on another agent kind, or on an +`invocations_ws`-only agent, which does not go through the content-safety HTTP +proxy — fails validation rather than silently deploying a policy that never runs. > **Understanding `responseMode`:** it declares which response *shapes* the > container can produce, **not** "input and output". Input is always moderated. 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 4e692847c88..f6c9651ebb0 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 @@ -389,6 +389,13 @@ func ValidateAgentDefinition(templateBytes []byte) error { errors = append(errors, fmt.Sprintf("template.name not in valid format: %v", err)) } + // Only hosted agents carry policies to the service, so a moderation block on any + // other kind would be dropped silently instead of enforced. + if agentDef.Kind != AgentKindHosted { + errors = append(errors, + validateInvocationsModerationKind(templateBytes, agentDef.Kind)...) + } + switch AgentKind(agentDef.Kind) { case AgentKindHosted: var agent ContainerAgent @@ -478,6 +485,30 @@ func ValidateAgentName(name string) error { return nil } +// validateInvocationsModerationKind reports invocationsModeration blocks declared on a +// non-hosted agent. Only ContainerAgent carries policies through to the service, so such a +// block would be dropped silently rather than enforced. It reads a minimal envelope because +// the kind-specific structs for the other kinds have no policies field at all. +func validateInvocationsModerationKind(templateBytes []byte, kind AgentKind) []string { + var envelope struct { + Policies []Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + } + if err := yaml.Unmarshal(templateBytes, &envelope); err != nil { + // A malformed document is reported by the kind-specific parse instead. + return nil + } + + var errors []string + for i, policy := range envelope.Policies { + if policy.InvocationsModeration != nil { + errors = append(errors, fmt.Sprintf( + "policies[%d] invocationsModeration is only supported for '%s' agents, got kind '%s'", + i, AgentKindHosted, kind)) + } + } + return errors +} + // validateInvocationsModeration checks a policy's invocations-moderation block against the // same structural rules the Agents service applies at create time, so a misconfiguration is // caught locally instead of surfacing later as an opaque 'invalid_payload' response. 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 62b2082bd8b..b6d7bf6af00 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 @@ -1465,3 +1465,93 @@ protocols: }) } } + +// TestValidateAgentDefinition_InvocationsModerationRequiresHostedKind covers the kinds that +// have no policies field of their own. Without an explicit check they would parse cleanly and +// the moderation block would be dropped on the way to the service rather than enforced. +func TestValidateAgentDefinition_InvocationsModerationRequiresHostedKind(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yaml string + wantErrSubst string + }{ + { + name: "prompt-voice agent", + yaml: `kind: prompt-voice +name: voice-agent +model: + id: gpt-4o-realtime-preview +policies: + - type: rai_policy + rai_policy_name: /subscriptions/x/raiPolicies/p + invocations_moderation: + response_mode: non_streaming + input_paths: ["$.input"] + output_paths: ["$.output"] +`, + wantErrSubst: "policies[0] invocationsModeration is only supported for 'hosted' agents, " + + "got kind 'prompt-voice'", + }, + { + name: "workflow agent", + yaml: `kind: workflow +name: workflow-agent +policies: + - type: rai_policy + rai_policy_name: /subscriptions/x/raiPolicies/p + invocations_moderation: + response_mode: non_streaming + input_paths: ["$.input"] + output_paths: ["$.output"] +`, + wantErrSubst: "policies[0] invocationsModeration is only supported for 'hosted' agents, " + + "got kind 'workflow'", + }, + { + name: "reported under the offending policy index", + yaml: `kind: workflow +name: workflow-agent +policies: + - type: rai_policy + rai_policy_name: /subscriptions/x/raiPolicies/first + - type: rai_policy + rai_policy_name: /subscriptions/x/raiPolicies/second + invocations_moderation: + response_mode: non_streaming + input_paths: ["$.input"] + output_paths: ["$.output"] +`, + wantErrSubst: "policies[1] invocationsModeration is only supported for 'hosted' agents", + }, + { + name: "a non-hosted agent without the block stays valid", + yaml: `kind: workflow +name: workflow-agent +policies: + - type: rai_policy + rai_policy_name: /subscriptions/x/raiPolicies/p +`, + }, + } + + 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()) + } + }) + } +} From 022ea71738972cdf54b056953f372da31645856f Mon Sep 17 00:00:00 2001 From: amitbhave10 Date: Tue, 18 Aug 2026 15:15:54 +0000 Subject: [PATCH 6/6] fix(agents): validate invocationsModeration on inline camelCase definitions Address maintainer review feedback on the invocationsModeration support. validateInvocationsModerationKind decoded the policy list into [Policy], whose struct tags the YAML library resolves via `yaml:` only. Inline azure.yaml services are validated from the raw property map, so they still carry the camelCase keys the user authored. The snake_case-tagged field never matched, InvocationsModeration stayed nil, and the block was dropped silently for every non-hosted inline definition -- exactly the case the guard exists to catch. Decode raw maps and accept both spellings. Also reject more than one policy of type 'rai_policy'. rai_config is a single object on the wire and the mapper takes the first match, so a second policy's moderation block would validate cleanly and then be discarded. Tests cover the camelCase non-hosted case (verified failing before the fix) at both the agent_yaml and internal/project entry points, plus the multi-policy rejection. Fixes #9597 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d91d7b94-da15-4dac-b0d4-195fad8fa01b --- .../internal/pkg/agents/agent_yaml/parse.go | 38 +++++++++-- .../pkg/agents/agent_yaml/parse_test.go | 65 +++++++++++++++++++ .../internal/project/agent_policies_test.go | 48 ++++++++++++++ 3 files changed, 146 insertions(+), 5 deletions(-) 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 f6c9651ebb0..996eae8a561 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 @@ -400,9 +400,11 @@ func ValidateAgentDefinition(templateBytes []byte) error { case AgentKindHosted: var agent ContainerAgent if err := yaml.Unmarshal(templateBytes, &agent); err == nil { + raiPolicyCount := 0 for i, policy := range agent.Policies { switch policy.Type { case PolicyTypeRai: + raiPolicyCount++ if policy.RaiPolicyName == "" { errors = append(errors, fmt.Sprintf( "policies[%d] of type '%s' requires a policy name "+ @@ -420,6 +422,14 @@ func ValidateAgentDefinition(templateBytes []byte) error { i, policy.Type, PolicyTypeRai)) } } + // rai_config carries a single policy on the wire, so only the first + // rai_policy would ever reach the service. Reject the ambiguity rather + // than silently dropping the rest. + if raiPolicyCount > 1 { + errors = append(errors, fmt.Sprintf( + "policies declares %d policies of type '%s', but only one is supported", + raiPolicyCount, PolicyTypeRai)) + } // TODO: Do we need this? // if len(agent.Models) == 0 { // errors = append(errors, "template.models is required and must not be empty") @@ -489,9 +499,15 @@ func ValidateAgentName(name string) error { // non-hosted agent. Only ContainerAgent carries policies through to the service, so such a // block would be dropped silently rather than enforced. It reads a minimal envelope because // the kind-specific structs for the other kinds have no policies field at all. +// +// The policies are decoded as raw maps rather than into [Policy] because the two authoring +// surfaces spell the key differently: a standalone agent.yaml uses the snake_case YAML tags, +// while an inline azure.yaml service is validated from the raw property map and therefore +// still carries the camelCase keys the user authored. Decoding into [Policy] would only ever +// match the snake_case spelling and let every inline definition bypass this check. func validateInvocationsModerationKind(templateBytes []byte, kind AgentKind) []string { var envelope struct { - Policies []Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + Policies []map[string]any `json:"policies,omitempty" yaml:"policies,omitempty"` } if err := yaml.Unmarshal(templateBytes, &envelope); err != nil { // A malformed document is reported by the kind-specific parse instead. @@ -500,15 +516,27 @@ func validateInvocationsModerationKind(templateBytes []byte, kind AgentKind) []s var errors []string for i, policy := range envelope.Policies { - if policy.InvocationsModeration != nil { - errors = append(errors, fmt.Sprintf( - "policies[%d] invocationsModeration is only supported for '%s' agents, got kind '%s'", - i, AgentKindHosted, kind)) + if !hasInvocationsModeration(policy) { + continue } + errors = append(errors, fmt.Sprintf( + "policies[%d] invocationsModeration is only supported for '%s' agents, got kind '%s'", + i, AgentKindHosted, kind)) } return errors } +// hasInvocationsModeration reports whether a raw policy map declares a moderation block under +// either the camelCase (azure.yaml) or snake_case (agent.yaml) spelling. +func hasInvocationsModeration(policy map[string]any) bool { + for _, key := range []string{"invocationsModeration", "invocations_moderation"} { + if value, ok := policy[key]; ok && value != nil { + return true + } + } + return false +} + // validateInvocationsModeration checks a policy's invocations-moderation block against the // same structural rules the Agents service applies at create time, so a misconfiguration is // caught locally instead of surfacing later as an opaque 'invalid_payload' response. 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 b6d7bf6af00..8a1fb509832 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 @@ -1534,8 +1534,73 @@ policies: rai_policy_name: /subscriptions/x/raiPolicies/p `, }, + { + name: "camelCase block on a non-hosted agent", + yaml: `kind: workflow +name: workflow-agent +policies: + - type: rai_policy + raiPolicyName: /subscriptions/x/raiPolicies/p + invocationsModeration: + responseMode: non_streaming + inputPaths: ["$.input"] + outputPaths: ["$.output"] +`, + wantErrSubst: "policies[0] invocationsModeration is only supported for 'hosted' agents", + }, } + runValidateAgentDefinitionCases(t, tests) +} + +// TestValidateAgentDefinition_SingleRaiPolicy pins the one-policy rule. rai_config is a single +// object on the wire, so a second rai_policy (and any moderation block it carries) would be +// dropped by the mapper after passing validation rather than enforced. +func TestValidateAgentDefinition_SingleRaiPolicy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yaml string + wantErrSubst string + }{ + { + name: "a single rai policy stays valid", + yaml: `kind: hosted +name: hosted-agent +image: myregistry.azurecr.io/agent:v1 +policies: + - type: rai_policy + rai_policy_name: /subscriptions/x/raiPolicies/p1 +`, + }, + { + name: "two rai policies are rejected", + yaml: `kind: hosted +name: hosted-agent +image: myregistry.azurecr.io/agent:v1 +policies: + - type: rai_policy + rai_policy_name: /subscriptions/x/raiPolicies/p1 + - type: rai_policy + rai_policy_name: /subscriptions/x/raiPolicies/p2 +`, + wantErrSubst: "policies declares 2 policies of type 'rai_policy', but only one is supported", + }, + } + + runValidateAgentDefinitionCases(t, tests) +} + +// runValidateAgentDefinitionCases runs a table of definitions through ValidateAgentDefinition, +// asserting either success or that the error mentions the expected substring. +func runValidateAgentDefinitionCases(t *testing.T, tests []struct { + name string + yaml string + wantErrSubst string +}, +) { + t.Helper() for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go index f619a5a4725..0fb7423f327 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go @@ -362,3 +362,51 @@ func TestAgentPoliciesInvocationsModerationInlineValidation(t *testing.T) { }) } } + +// TestAgentPoliciesInvocationsModerationNonHostedInline covers the production entry point for +// the non-hosted kinds. Those services are validated from the raw inline property map, so the +// validator sees the camelCase keys the user authored rather than the snake_case YAML tags — +// a block reaching the service would be dropped instead of enforced. +func TestAgentPoliciesInvocationsModerationNonHostedInline(t *testing.T) { + t.Parallel() + + for _, kind := range []string{"workflow", "prompt-voice"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + + _, _, _, _, err := AgentDefinitionFromService(inlineAgentService(t, map[string]any{ + "kind": kind, + "name": "rai-agent", + "policies": []any{ + map[string]any{ + "type": "rai_policy", + "raiPolicyName": raiPolicyID, + "invocationsModeration": map[string]any{ + "responseMode": "non_streaming", + "inputPaths": []any{"$.input"}, + "outputPaths": []any{"$.output"}, + }, + }, + }, + })) + require.ErrorContains(t, err, "invocationsModeration is only supported for 'hosted' agents") + }) + } +} + +// TestAgentPoliciesSingleRaiPolicyInline pins the one-policy rule on the inline shape, where a +// second rai_policy would otherwise validate cleanly and then be dropped by the mapper. +func TestAgentPoliciesSingleRaiPolicyInline(t *testing.T) { + t.Parallel() + + _, _, _, _, err := AgentDefinitionFromService(inlineAgentService(t, map[string]any{ + "kind": "hosted", + "name": "rai-agent", + "image": "myregistry.azurecr.io/agent:v1", + "policies": []any{ + map[string]any{"type": "rai_policy", "raiPolicyName": raiPolicyID}, + map[string]any{"type": "rai_policy", "raiPolicyName": raiPolicyID + "-2"}, + }, + })) + require.ErrorContains(t, err, "only one is supported") +}