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
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ require (
github.com/ory/dockertest/v3 v3.12.0
github.com/rs/zerolog v1.34.0
github.com/sigstore/sigstore v1.10.8
github.com/slack-go/slack v0.23.1
github.com/slack-go/slack v0.30.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.12.0
github.com/stretchr/testify v1.12.1
github.com/tdewolff/parse/v2 v2.8.13
github.com/tsawler/tabula v1.6.14
github.com/warpstreamlabs/bento v1.17.0
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -860,8 +860,8 @@ github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/slack-go/slack v0.23.1 h1:ZS5B96wxxYQRwvJ3/vJFtqtUZi3tXhsZCyT44Nv7M80=
github.com/slack-go/slack v0.23.1/go.mod h1:H0yR/YBuRJ39RkE+JpV/d/oEsbanzTRowR82bCN0cEs=
github.com/slack-go/slack v0.30.1 h1:q6FjZssqts6hJ+DRWCnnO2YVUh+E3/UNrC0EIO2xci8=
github.com/slack-go/slack v0.30.1/go.mod h1:radSmEe8GEgE+aK980ZafMR0AQhiZjADptN5k/gOw34=
github.com/snowflakedb/gosnowflake v1.18.1 h1:Nb4AWSnSBWe1UKpKTwCZxjYhYo1JH7GgKhO3wW1kR10=
github.com/snowflakedb/gosnowflake v1.18.1/go.mod h1:7D4+cLepOWrerVsH+tevW3zdMJ5/WrEN7ZceAC6xBv0=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
Expand Down Expand Up @@ -893,8 +893,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick=
Expand Down
82 changes: 66 additions & 16 deletions pkg/agent/tool/meta/respond.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,16 @@ func New(cfg RespondConfig) tool.Tool {
}

type respondTool struct {
cfg RespondConfig
schema json.RawMessage
desc string
hasAsset bool
cfg RespondConfig
schema json.RawMessage
desc string
hasAsset bool
hasComponents bool
// components validates and projects agent-authored `blocks`. Resolved from
// the outbound kind at construction; nil when the kind advertises no
// "components" capability, or (a wiring bug) advertises it without a
// validator — Execute fails closed on the latter.
components channelkinds.ComponentValidator
}

func newRespondTool(cfg RespondConfig) *respondTool {
Expand All @@ -109,27 +115,33 @@ func newRespondTool(cfg RespondConfig) *respondTool {
}
}

// WHAT a reply may carry and HOW it is authored are the KIND's facts, not
// this tool's: the rules land in model-facing prompt text the runner
// presents as channel-specific and authoritative, so asking the registry
// keeps that promise for every registered kind. An unregistered name yields
// a nil Kind — TextFormattingInstructionsFor answers with the generic
// CommonMark line, and ComponentValidatorFor answers with nil.
kind, _ := chregistry.Get(cfg.ChannelKind)

props := map[string]any{}
required := []string{"text"}
textDesc := "The message text."
if hasMarkdown {
// WHICH markup dialect this surface speaks is the KIND's fact, not this
// tool's: the rules land in model-facing prompt text the runner presents
// as channel-specific and authoritative, so asking the registry keeps
// that promise for every registered kind. An unregistered name yields a
// nil Kind, which TextFormattingInstructionsFor answers with the generic
// CommonMark line.
kind, _ := chregistry.Get(cfg.ChannelKind)
textDesc += " " + channelkinds.TextFormattingInstructionsFor(kind)
} else {
textDesc += " Plain text only — markdown will be sent literally."
}
props["text"] = map[string]any{"type": "string", "description": textDesc}

var components channelkinds.ComponentValidator
if hasComponents {
components = channelkinds.ComponentValidatorFor(kind)
desc := "Optional structured UI components in the channel-kind's native format, rendered as the message body with `text` as the notification preview and fallback. " +
channelkinds.ComponentFormattingInstructionsFor(kind)
props["blocks"] = map[string]any{
"type": "array",
"description": "Optional structured components in the channel-kind's native format.",
"description": desc,
"items": map[string]any{"type": "object"},
}
}

Expand All @@ -156,14 +168,17 @@ func newRespondTool(cfg RespondConfig) *respondTool {
} else {
descParts = " Plain text only on this channel."
}
if hasComponents {
descParts += " Structured `blocks` components are supported."
}
desc := fmt.Sprintf(
"Send a reply to the user via the %s channel.%s Call this whenever you have a message to surface. "+
"After your reply is complete, call await_user_message to yield until the user responds, "+
"or call agent_work_complete if the current work item is done and the session should go idle.",
cfg.ChannelKind, descParts,
)

return &respondTool{cfg: cfg, schema: schemaBytes, desc: desc, hasAsset: hasAsset}
return &respondTool{cfg: cfg, schema: schemaBytes, desc: desc, hasAsset: hasAsset, hasComponents: hasComponents, components: components}
}

func (t *respondTool) Name() string { return "respond_to_user" }
Expand Down Expand Up @@ -194,6 +209,10 @@ func (t *respondTool) Execute(ctx context.Context, args json.RawMessage, sess *t
// Attached are artifact handles to attach; each is gated on the channel's
// asset:* capability.
Attached []string `json:"attached,omitempty"`
// Blocks are agent-authored structured components in the channel-kind's
// native format, gated on the channel's "components" capability and
// validated by the kind before publish.
Blocks json.RawMessage `json:"blocks,omitempty"`
}
if res, ok := tool.ParseArgs(args, &in, t.Name(), `{"text": "the message body"}`); !ok {
return res, nil
Expand All @@ -202,6 +221,25 @@ func (t *respondTool) Execute(ctx context.Context, args json.RawMessage, sess *t
return tool.Result{Content: "respond_to_user: `text` is required and must be non-empty. Pass the full message body you want the user to see, e.g. {\"text\": \"Here are the results: …\"}. If your previous reply was truncated, you may need to shorten it before retrying.", IsError: true, Trusted: true}, nil
}

// Validate agent-authored components BEFORE anything is published: a
// rejection returns IsError with a detailed, correctable message and leaves
// no envelope on the wire, exactly like the attachment-resolution guard
// below. Only the kind knows its component format, so the kind's validator
// (resolved from the outbound binding at construction) is the authority.
if len(in.Blocks) > 0 {
if !t.hasComponents {
return tool.Result{Content: "respond_to_user: `blocks` (structured components) are not supported on this channel. Put your reply in `text` instead.", IsError: true, Trusted: true}, nil
}
if t.components == nil {
// Advertised "components" but no validator: a wiring bug, not the
// model's fault. Fail closed rather than publish unvalidated blocks.
return tool.Result{Trusted: true}, fmt.Errorf("respond_to_user: channel kind %q advertises components but supplies no validator", t.cfg.ChannelKind)
}
if err := t.components.ValidateComponents(in.Blocks); err != nil {
return tool.Result{Content: err.Error(), IsError: true, Trusted: true}, nil
}
}

// Resolve attached artifact handles BEFORE publishing — any failure
// returns IsError without putting the envelope on the wire so the
// model can correct and retry.
Expand Down Expand Up @@ -233,7 +271,19 @@ func (t *respondTool) Execute(ctx context.Context, args json.RawMessage, sess *t
// internal config (approver subject, SpiceDB endpoint) that must not reach
// channel members.
if t.cfg.LeakageGate != nil {
if err := t.cfg.LeakageGate(ctx, sess, in.Text, attachments); err != nil {
// The gate measures TEXT. Components carry their own text, so feed the
// gate the block content too (projected to plain text by the kind) —
// otherwise an agent could route blockable content through `blocks` and
// bypass the gate, a fail-open egress path. Appended to the TAGGED
// in.Text the gate already runs on; the block text is untagged and so
// falls to the gate's coarse check, same as an attachment does.
gateText := in.Text
if len(in.Blocks) > 0 && t.components != nil {
if projected := t.components.ComponentsPlainText(in.Blocks); projected != "" {
gateText = in.Text + "\n\n" + projected
}
}
if err := t.cfg.LeakageGate(ctx, sess, gateText, attachments); err != nil {
logger := log.FromContext(ctx)
pub := func(subject string, data []byte) error {
return publishWithRetry(ctx, t.cfg.NATSPublish, subject, data)
Expand Down Expand Up @@ -309,13 +359,13 @@ func (t *respondTool) Execute(ctx context.Context, args json.RawMessage, sess *t
toolUseID = ids.ToolUseID
}
logger.Info("respond_to_user: publishing user_message envelope",
"session", sess.Namespace+"/"+sess.Name, "toolUseID", toolUseID, "textLen", len(outText), "attachments", len(attachments))
"session", sess.Namespace+"/"+sess.Name, "toolUseID", toolUseID, "textLen", len(outText), "attachments", len(attachments), "components", len(in.Blocks) > 0)
publish := func(subject string, data []byte) error {
return publishWithRetry(ctx, t.cfg.NATSPublish, subject, data)
}
if err := t.cfg.EnvelopeSigner.PublishOut(publish, sess.Namespace, sess.Name,
channelevents.KindUserMessage,
channelevents.OutboundUserMessagePayload{Text: outText, Attachments: attachments},
channelevents.OutboundUserMessagePayload{Text: outText, Attachments: attachments, Components: in.Blocks},
); err != nil {
return tool.Result{Content: fmt.Sprintf("publish failed: %v", err), IsError: true, Trusted: true}, nil
}
Expand Down
125 changes: 125 additions & 0 deletions pkg/agent/tool/meta/respond_components_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package meta

import (
"context"
"encoding/json"
"errors"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/authzed/openagentprimitives/pkg/agent/tool"
"github.com/authzed/openagentprimitives/pkg/channels/channelevents"
// Registers the slack kind so ChannelKind "slack" resolves its real
// ComponentFormatter/ComponentValidator — the same wiring the runner uses.
_ "github.com/authzed/openagentprimitives/pkg/channels/channelkinds/slack"
"github.com/authzed/openagentprimitives/pkg/memory"
)

func respondCtx() context.Context {
return memory.WithSystemApproval(context.Background(), "test")
}

func TestRespondSchemaIncludesBlocksWithComponents(t *testing.T) {
tt := newRespondTool(RespondConfig{
Capabilities: []string{"text", "markdown", "components"},
ChannelKind: "slack",
})
var schema map[string]any
require.NoError(t, json.Unmarshal(tt.InputSchema(), &schema))
props, _ := schema["properties"].(map[string]any)

blocks, ok := props["blocks"].(map[string]any)
require.True(t, ok, "blocks property present when 'components' capability is set")
desc, _ := blocks["description"].(string)
assert.Contains(t, desc, "rich_text", "blocks description carries the kind's authoring instructions, not the old stub text")
assert.Contains(t, tt.Description(), "blocks", "tool description advertises components")
}

func TestRespondComponentsNotSupportedChannel_IsErrorNoPublish(t *testing.T) {
calls := 0
tt := newRespondTool(RespondConfig{
Capabilities: []string{"text"}, // no "components"
ChannelKind: "fake",
NATSPublish: func(context.Context, string, []byte) error { calls++; return nil },
NATSSubjectPrefix: "ap.session.default.foo",
})
res, err := tt.Execute(respondCtx(),
json.RawMessage(`{"text":"hi","blocks":[{"type":"divider"}]}`),
&tool.SessionContext{Namespace: "default", Name: "foo"})
require.NoError(t, err)
assert.True(t, res.IsError, "blocks on a channel without the components capability must be an error")
assert.Contains(t, res.Content, "not supported")
assert.Equal(t, 0, calls, "nothing may be published when components are unsupported")
}

func TestRespondComponentsValidationFailure_IsErrorNoPublish(t *testing.T) {
calls := 0
tt := newRespondTool(RespondConfig{
Capabilities: []string{"text", "components"},
ChannelKind: "slack",
NATSPublish: func(context.Context, string, []byte) error { calls++; return nil },
NATSSubjectPrefix: "ap.session.default.foo",
})
// An actions block is rejected by the slack validator (interactive).
args := `{"text":"hi","blocks":[{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"Go"},"action_id":"a"}]}]}`
res, err := tt.Execute(respondCtx(), json.RawMessage(args),
&tool.SessionContext{Namespace: "default", Name: "foo"})
require.NoError(t, err)
assert.True(t, res.IsError, "invalid components must surface IsError")
assert.Contains(t, res.Content, "interactive", "the detailed validator error must reach the model")
assert.Equal(t, 0, calls, "no envelope may be published when validation fails")
}

func TestRespondComponentsSuccess_CarriesComponentsOnPayload(t *testing.T) {
var published [][]byte
tt := newRespondTool(RespondConfig{
Capabilities: []string{"text", "markdown", "components"},
ChannelKind: "slack",
NATSPublish: func(_ context.Context, _ string, p []byte) error { published = append(published, p); return nil },
NATSSubjectPrefix: "ap.session.default.foo",
})
blocksJSON := `[{"type":"section","text":{"type":"mrkdwn","text":"*hi*"}}]`
args := `{"text":"summary","blocks":` + blocksJSON + `}`
res, err := tt.Execute(respondCtx(), json.RawMessage(args),
&tool.SessionContext{Namespace: "default", Name: "foo"})
require.NoError(t, err)
require.False(t, res.IsError, "valid components must publish; got: %s", res.Content)
require.Len(t, published, 1)

var env channelevents.Envelope
require.NoError(t, json.Unmarshal(published[0], &env))
var pl channelevents.OutboundUserMessagePayload
require.NoError(t, json.Unmarshal(env.Payload, &pl))
assert.Equal(t, "summary", pl.Text, "text is carried as the fallback")
assert.JSONEq(t, blocksJSON, string(pl.Components), "validated components are carried on the payload")
}

// The leakage gate measures text; without feeding it the block content, an agent
// could route blockable content through blocks and bypass the gate. This proves
// the gate sees the block text even when the reply text is innocuous.
func TestRespondComponentsLeakageGateSeesBlockText(t *testing.T) {
var sawGateText string
tt := newRespondTool(RespondConfig{
Capabilities: []string{"text", "components"},
ChannelKind: "slack",
NATSPublish: func(context.Context, string, []byte) error { return nil },
NATSSubjectPrefix: "ap.session.default.foo",
LeakageGate: func(_ context.Context, _ *tool.SessionContext, text string, _ []channelevents.AttachmentRef) error {
sawGateText = text
if strings.Contains(text, "SECRET") {
return errors.New("blocked by audience check")
}
return nil
},
})
// "SECRET" appears only in the block, not in the reply text.
args := `{"text":"all clear","blocks":[{"type":"section","text":{"type":"mrkdwn","text":"the SECRET value is 42"}}]}`
res, err := tt.Execute(respondCtx(), json.RawMessage(args),
&tool.SessionContext{Namespace: "default", Name: "foo"})
require.NoError(t, err)
assert.Contains(t, sawGateText, "SECRET", "the gate must measure block text, not just the reply text")
assert.True(t, res.IsError, "the gate blocked on content carried only in blocks")
}
8 changes: 8 additions & 0 deletions pkg/channels/channelevents/envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,14 @@ type OutboundUserMessagePayload struct {
// time via the operator's HTTP /artifact/... endpoint and never travel
// via NATS. Empty means a text-only reply.
Attachments []AttachmentRef `json:"attachments,omitempty"`
// Components is agent-authored structured UI in the OUTBOUND kind's native
// format (Slack Block Kit today), already validated by that kind's
// ComponentValidator before this payload was published. The kind's Sender
// renders it as the message body with Text as the notification/degrade
// fallback; a kind that does not support components ignores it. Empty means
// a text-only (or text+attachments) reply. Opaque to channelsd — format is
// a kind concern, never an envelope one.
Components json.RawMessage `json:"components,omitempty"`
}

// AttachmentRef references an ArtifactRender CR whose status.outputRef
Expand Down
29 changes: 29 additions & 0 deletions pkg/channels/channelevents/envelope_components_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package channelevents

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestOutboundUserMessagePayloadComponents(t *testing.T) {
t.Run("present when set: round-trips", func(t *testing.T) {
p := OutboundUserMessagePayload{Text: "hi", Components: json.RawMessage(`[{"type":"divider"}]`)}
b, err := json.Marshal(p)
require.NoError(t, err)
assert.Contains(t, string(b), `"components"`)

var back OutboundUserMessagePayload
require.NoError(t, json.Unmarshal(b, &back))
assert.Equal(t, "hi", back.Text)
assert.JSONEq(t, `[{"type":"divider"}]`, string(back.Components))
})

t.Run("omitted when empty", func(t *testing.T) {
b, err := json.Marshal(OutboundUserMessagePayload{Text: "hi"})
require.NoError(t, err)
assert.NotContains(t, string(b), "components")
})
}
Loading
Loading