Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions cli/azd/extensions/azure.ai.agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<subscription-id>/resourceGroups/<resource-group>/providers/Microsoft.CognitiveServices/accounts/<account-name>/raiPolicies/<policy-name>
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 carry-over across deploys

When a hosted agent is redeployed, Foundry assigns the agent a **new version** and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"maps"
"math"
"regexp"
"slices"
"strings"

"azureaiagent/internal/pkg/agents/agent_api"
Expand Down Expand Up @@ -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),
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the first named rai_policy is mapped into rai_config. A later policy's valid invocationsModeration passes validation but is silently ignored. Could we define how multiple RAI policies should be handled?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid — mapRaiConfig returns on the first rai_policy match. That first-match-wins behaviour predates this PR, but it becomes a correctness gap now that validation loops over every policy: a second rai_policy carrying an invocationsModeration block would validate cleanly and then be discarded by the mapper, so the user would get no guardrail with no warning.

rai_config is a single object on the wire, so more than one rai_policy isn't expressible. Rather than change the mapper's precedence (any choice there is arbitrary and still drops user intent), 478cb5d rejects the ambiguity up front:

policies declares 2 policies of type 'rai_policy', but only one is supported

Consistent with the rest of this PR's fail-fast-locally approach. Covered by TestValidateAgentDefinition_SingleRaiPolicy and TestAgentPoliciesSingleRaiPolicyInline. Confirmed no existing test, testdata file, or README example declares two rai_policy entries in one list, so this is not a breaking change in practice.

Happy to narrow it to "only reject when a second policy carries invocationsModeration" if you'd prefer the softer rule — just say the word.

}
}
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package agent_yaml
import (
"encoding/json"
"math"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -1729,3 +1730,151 @@ func TestMapRaiConfig(t *testing.T) {
t.Errorf("mapRaiConfig(p1) = %+v, want RaiPolicyName=p1", got)
}
}

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)
}
}
}
Loading
Loading