diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index 80df72db3ac..4ddac596f9a 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` or omitted (it defaults to `json`) | JSONPath expressions selecting the request text. | +| `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 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. +> 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..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 @@ -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 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"` + // 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"` + 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..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 @@ -6,6 +6,7 @@ package agent_yaml import ( "fmt" "regexp" + "slices" "strings" "go.yaml.in/yaml/v3" @@ -388,19 +389,30 @@ 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 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 "+ "('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)) @@ -410,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") @@ -474,3 +494,168 @@ 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. +// +// 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 []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. + return nil + } + + var errors []string + for i, policy := range envelope.Policies { + 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. +// +// 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 + + // 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) { + 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)} + } + + 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. 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 "": + 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..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 @@ -1186,3 +1186,437 @@ 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. + // 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: + - 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 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", + 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'", + // 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", + 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", + // 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", + 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()) + } + if tc.notWantErrSubst != "" && strings.Contains(err.Error(), tc.notWantErrSubst) { + t.Fatalf("expected error NOT to contain %q, got %q", tc.notWantErrSubst, err.Error()) + } + }) + } +} + +// 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 +`, + }, + { + 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() + 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..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 @@ -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 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"` + // 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/internal/project/agent_policies_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go index 5b60bd73513..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 @@ -199,3 +199,214 @@ 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) + }) + } +} + +// 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") +} 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..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 @@ -181,11 +181,101 @@ "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' 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' 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' 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 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." } + }, + "required": ["eventType"], + "additionalProperties": false + }, "ContainerSettings": { "type": "object", "description": "Container configuration for the Azure AI Agent Service target",