feat(filters): Add configurable filters for incoming jobs

Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
Richard Palethorpe
2025-04-29 21:07:40 +01:00
parent 02c6b5ad4e
commit f2c3b9dbdb
19 changed files with 511 additions and 5 deletions

View File

@@ -73,6 +73,8 @@ Still having issues? see this Youtube video: https://youtu.be/HtVwIxW3ePg
[![Creating a basic agent](https://img.youtube.com/vi/HtVwIxW3ePg/mqdefault.jpg)](https://youtu.be/HtVwIxW3ePg) [![Creating a basic agent](https://img.youtube.com/vi/HtVwIxW3ePg/mqdefault.jpg)](https://youtu.be/HtVwIxW3ePg)
[![Agent Observability](https://img.youtube.com/vi/v82rswGJt_M/mqdefault.jpg)](https://youtu.be/v82rswGJt_M) [![Agent Observability](https://img.youtube.com/vi/v82rswGJt_M/mqdefault.jpg)](https://youtu.be/v82rswGJt_M)
[![Filters and Triggers](https://youtu.be/d_we-AYksSw/mqdefault.jpg)](https://youtu.be/d_we-AYksSw)
## 📚🆕 Local Stack Family ## 📚🆕 Local Stack Family

View File

@@ -492,6 +492,73 @@ func (a *Agent) processUserInputs(job *types.Job, role string, conv Messages) Me
return conv return conv
} }
func (a *Agent) filterJob(job *types.Job) (ok bool, err error) {
hasTriggers := false
triggeredBy := ""
failedBy := ""
if job.DoneFilter {
return true, nil
}
job.DoneFilter = true
if len(a.options.jobFilters) < 1 {
xlog.Debug("No filters")
return true, nil
}
for _, filter := range a.options.jobFilters {
name := filter.Name()
if triggeredBy != "" && filter.IsTrigger() {
continue
}
ok, err = filter.Apply(job)
if err != nil {
xlog.Error("Error in job filter", "filter", name, "error", err)
failedBy = name
break
}
if filter.IsTrigger() {
hasTriggers = true
if ok {
triggeredBy = name
xlog.Info("Job triggered by filter", "filter", name)
}
} else if !ok {
failedBy = name
xlog.Info("Job failed filter", "filter", name)
break
} else {
xlog.Debug("Job passed filter", "filter", name)
}
}
if a.Observer() != nil {
obs := a.Observer().NewObservable()
obs.Name = "filter"
obs.Icon = "shield"
obs.ParentID = job.Obs.ID
if err == nil {
obs.Completion = &types.Completion{
FilterResult: &types.FilterResult{
HasTriggers: hasTriggers,
TriggeredBy: triggeredBy,
FailedBy: failedBy,
},
}
} else {
obs.Completion = &types.Completion{
Error: err.Error(),
}
}
a.Observer().Update(*obs)
}
return failedBy == "" && (!hasTriggers || triggeredBy != ""), nil
}
func (a *Agent) consumeJob(job *types.Job, role string, retries int) { func (a *Agent) consumeJob(job *types.Job, role string, retries int) {
if err := job.GetContext().Err(); err != nil { if err := job.GetContext().Err(); err != nil {
@@ -533,6 +600,14 @@ func (a *Agent) consumeJob(job *types.Job, role string, retries int) {
} }
conv = a.processPrompts(conv) conv = a.processPrompts(conv)
if ok, err := a.filterJob(job); !ok || err != nil {
if err != nil {
job.Result.Finish(fmt.Errorf("Error in job filter: %w", err))
} else {
job.Result.Finish(nil)
}
return
}
conv = a.processUserInputs(job, role, conv) conv = a.processUserInputs(job, role, conv)
// RAG // RAG

View File

@@ -24,6 +24,7 @@ type options struct {
randomIdentityGuidance string randomIdentityGuidance string
randomIdentity bool randomIdentity bool
userActions types.Actions userActions types.Actions
jobFilters types.JobFilters
enableHUD, standaloneJob, showCharacter, enableKB, enableSummaryMemory, enableLongTermMemory bool enableHUD, standaloneJob, showCharacter, enableKB, enableSummaryMemory, enableLongTermMemory bool
stripThinkingTags bool stripThinkingTags bool
@@ -373,6 +374,13 @@ func WithActions(actions ...types.Action) Option {
} }
} }
func WithJobFilters(filters ...types.JobFilter) Option {
return func(o *options) error {
o.jobFilters = filters
return nil
}
}
func WithObserver(observer Observer) Option { func WithObserver(observer Observer) Option {
return func(o *options) error { return func(o *options) error {
o.observer = observer o.observer = observer

View File

@@ -31,6 +31,11 @@ func (d DynamicPromptsConfig) ToMap() map[string]string {
return config return config
} }
type FiltersConfig struct {
Type string `json:"type"`
Config string `json:"config"`
}
type AgentConfig struct { type AgentConfig struct {
Connector []ConnectorConfig `json:"connectors" form:"connectors" ` Connector []ConnectorConfig `json:"connectors" form:"connectors" `
Actions []ActionsConfig `json:"actions" form:"actions"` Actions []ActionsConfig `json:"actions" form:"actions"`
@@ -39,6 +44,7 @@ type AgentConfig struct {
MCPSTDIOServers []agent.MCPSTDIOServer `json:"mcp_stdio_servers" form:"mcp_stdio_servers"` MCPSTDIOServers []agent.MCPSTDIOServer `json:"mcp_stdio_servers" form:"mcp_stdio_servers"`
MCPPrepareScript string `json:"mcp_prepare_script" form:"mcp_prepare_script"` MCPPrepareScript string `json:"mcp_prepare_script" form:"mcp_prepare_script"`
MCPBoxURL string `json:"mcp_box_url" form:"mcp_box_url"` MCPBoxURL string `json:"mcp_box_url" form:"mcp_box_url"`
Filters []FiltersConfig `json:"filters" form:"filters"`
Description string `json:"description" form:"description"` Description string `json:"description" form:"description"`
@@ -71,6 +77,7 @@ type AgentConfig struct {
} }
type AgentConfigMeta struct { type AgentConfigMeta struct {
Filters []config.FieldGroup
Fields []config.Field Fields []config.Field
Connectors []config.FieldGroup Connectors []config.FieldGroup
Actions []config.FieldGroup Actions []config.FieldGroup
@@ -82,6 +89,7 @@ func NewAgentConfigMeta(
actionsConfig []config.FieldGroup, actionsConfig []config.FieldGroup,
connectorsConfig []config.FieldGroup, connectorsConfig []config.FieldGroup,
dynamicPromptsConfig []config.FieldGroup, dynamicPromptsConfig []config.FieldGroup,
filtersConfig []config.FieldGroup,
) AgentConfigMeta { ) AgentConfigMeta {
return AgentConfigMeta{ return AgentConfigMeta{
Fields: []config.Field{ Fields: []config.Field{
@@ -319,6 +327,7 @@ func NewAgentConfigMeta(
DynamicPrompts: dynamicPromptsConfig, DynamicPrompts: dynamicPromptsConfig,
Connectors: connectorsConfig, Connectors: connectorsConfig,
Actions: actionsConfig, Actions: actionsConfig,
Filters: filtersConfig,
} }
} }

View File

@@ -38,6 +38,7 @@ type AgentPool struct {
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action
connectors func(*AgentConfig) []Connector connectors func(*AgentConfig) []Connector
dynamicPrompt func(*AgentConfig) []DynamicPrompt dynamicPrompt func(*AgentConfig) []DynamicPrompt
filters func(*AgentConfig) types.JobFilters
timeout string timeout string
conversationLogs string conversationLogs string
} }
@@ -78,6 +79,7 @@ func NewAgentPool(
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action, availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action,
connectors func(*AgentConfig) []Connector, connectors func(*AgentConfig) []Connector,
promptBlocks func(*AgentConfig) []DynamicPrompt, promptBlocks func(*AgentConfig) []DynamicPrompt,
filters func(*AgentConfig) types.JobFilters,
timeout string, timeout string,
withLogs bool, withLogs bool,
) (*AgentPool, error) { ) (*AgentPool, error) {
@@ -110,6 +112,7 @@ func NewAgentPool(
connectors: connectors, connectors: connectors,
availableActions: availableActions, availableActions: availableActions,
dynamicPrompt: promptBlocks, dynamicPrompt: promptBlocks,
filters: filters,
timeout: timeout, timeout: timeout,
conversationLogs: conversationPath, conversationLogs: conversationPath,
}, nil }, nil
@@ -135,6 +138,7 @@ func NewAgentPool(
connectors: connectors, connectors: connectors,
localRAGAPI: LocalRAGAPI, localRAGAPI: LocalRAGAPI,
dynamicPrompt: promptBlocks, dynamicPrompt: promptBlocks,
filters: filters,
availableActions: availableActions, availableActions: availableActions,
timeout: timeout, timeout: timeout,
conversationLogs: conversationPath, conversationLogs: conversationPath,
@@ -337,6 +341,8 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
if config.Model != "" { if config.Model != "" {
model = config.Model model = config.Model
} else {
config.Model = model
} }
if config.MCPBoxURL != "" { if config.MCPBoxURL != "" {
@@ -347,12 +353,17 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
config.PeriodicRuns = "10m" config.PeriodicRuns = "10m"
} }
// XXX: Why do we update the pool config from an Agent's config?
if config.APIURL != "" { if config.APIURL != "" {
a.apiURL = config.APIURL a.apiURL = config.APIURL
} else {
config.APIURL = a.apiURL
} }
if config.APIKey != "" { if config.APIKey != "" {
a.apiKey = config.APIKey a.apiKey = config.APIKey
} else {
config.APIKey = a.apiKey
} }
if config.LocalRAGURL != "" { if config.LocalRAGURL != "" {
@@ -366,6 +377,7 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
connectors := a.connectors(config) connectors := a.connectors(config)
promptBlocks := a.dynamicPrompt(config) promptBlocks := a.dynamicPrompt(config)
actions := a.availableActions(config)(ctx, a) actions := a.availableActions(config)(ctx, a)
filters := a.filters(config)
stateFile, characterFile := a.stateFiles(name) stateFile, characterFile := a.stateFiles(name)
actionsLog := []string{} actionsLog := []string{}
@@ -378,6 +390,11 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
connectorLog = append(connectorLog, fmt.Sprintf("%+v", connector)) connectorLog = append(connectorLog, fmt.Sprintf("%+v", connector))
} }
filtersLog := []string{}
for _, filter := range filters {
filtersLog = append(filtersLog, filter.Name())
}
xlog.Info( xlog.Info(
"Creating agent", "Creating agent",
"name", name, "name", name,
@@ -385,6 +402,7 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
"api_url", a.apiURL, "api_url", a.apiURL,
"actions", actionsLog, "actions", actionsLog,
"connectors", connectorLog, "connectors", connectorLog,
"filters", filtersLog,
) )
// dynamicPrompts := []map[string]string{} // dynamicPrompts := []map[string]string{}
@@ -406,6 +424,7 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
WithMCPSTDIOServers(config.MCPSTDIOServers...), WithMCPSTDIOServers(config.MCPSTDIOServers...),
WithMCPBoxURL(a.mcpBoxURL), WithMCPBoxURL(a.mcpBoxURL),
WithPrompts(promptBlocks...), WithPrompts(promptBlocks...),
WithJobFilters(filters...),
WithMCPPrepareScript(config.MCPPrepareScript), WithMCPPrepareScript(config.MCPPrepareScript),
// WithDynamicPrompts(dynamicPrompts...), // WithDynamicPrompts(dynamicPrompts...),
WithCharacter(Character{ WithCharacter(Character{

15
core/types/filters.go Normal file
View File

@@ -0,0 +1,15 @@
package types
type JobFilter interface {
Name() string
Apply(job *Job) (bool, error)
IsTrigger() bool
}
type JobFilters []JobFilter
type FilterResult struct {
HasTriggers bool `json:"has_triggers"`
TriggeredBy string `json:"triggered_by,omitempty"`
FailedBy string `json:"failed_by,omitempty"`
}

View File

@@ -19,6 +19,7 @@ type Job struct {
ConversationHistory []openai.ChatCompletionMessage ConversationHistory []openai.ChatCompletionMessage
UUID string UUID string
Metadata map[string]interface{} Metadata map[string]interface{}
DoneFilter bool
pastActions []*ActionRequest pastActions []*ActionRequest
nextAction *Action nextAction *Action

View File

@@ -24,7 +24,8 @@ type Completion struct {
ChatCompletionResponse *openai.ChatCompletionResponse `json:"chat_completion_response,omitempty"` ChatCompletionResponse *openai.ChatCompletionResponse `json:"chat_completion_response,omitempty"`
Conversation []openai.ChatCompletionMessage `json:"conversation,omitempty"` Conversation []openai.ChatCompletionMessage `json:"conversation,omitempty"`
ActionResult string `json:"action_result,omitempty"` ActionResult string `json:"action_result,omitempty"`
AgentState *AgentInternalState `json:"agent_state"` AgentState *AgentInternalState `json:"agent_state,omitempty"`
FilterResult *FilterResult `json:"filter_result,omitempty"`
} }
type Observable struct { type Observable struct {

View File

@@ -70,6 +70,7 @@ func main() {
}), }),
services.Connectors, services.Connectors,
services.DynamicPrompts, services.DynamicPrompts,
services.Filters,
timeout, timeout,
withLogs, withLogs,
) )

44
services/filters.go Normal file
View File

@@ -0,0 +1,44 @@
package services
import (
"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/LocalAGI/services/filters"
)
func Filters(a *state.AgentConfig) types.JobFilters {
var result []types.JobFilter
for _, f := range a.Filters {
var filter types.JobFilter
var err error
switch f.Type {
case filters.FilterRegex:
filter, err = filters.NewRegexFilter(f.Config)
if err != nil {
xlog.Error("Failed to configure regex", "err", err.Error())
continue
}
case filters.FilterClassifier:
filter, err = filters.NewClassifierFilter(f.Config, a)
if err != nil {
xlog.Error("failed to configure classifier", "err", err.Error())
continue
}
default:
xlog.Error("Unrecognized filter type", "type", f.Type)
continue
}
result = append(result, filter)
}
return result
}
// FiltersConfigMeta returns all filter config metas for UI.
func FiltersConfigMeta() []config.FieldGroup {
return []config.FieldGroup{
filters.RegexFilterConfigMeta(),
filters.ClassifierFilterConfigMeta(),
}
}

View File

@@ -0,0 +1,121 @@
package filters
import (
"encoding/json"
"fmt"
"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/llm"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
)
const FilterClassifier = "classifier"
type ClassifierFilter struct {
name string
client *openai.Client
model string
description string
allowOnMatch bool
isTrigger bool
}
type ClassifierFilterConfig struct {
Name string `json:"name"`
Model string `json:"model,omitempty"`
APIURL string `json:"api_url,omitempty"`
Description string `json:"description"`
AllowOnMatch bool `json:"allow_on_match"`
IsTrigger bool `json:"is_trigger"`
}
func NewClassifierFilter(configJSON string, a *state.AgentConfig) (*ClassifierFilter, error) {
var cfg ClassifierFilterConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
return nil, err
}
var model string
if cfg.Model != "" {
model = cfg.Model
} else {
model = a.Model
}
if cfg.Name == "" {
return nil, fmt.Errorf("Classifier with no name")
}
if cfg.Description == "" {
return nil, fmt.Errorf("%s classifier has no description", cfg.Name)
}
apiUrl := a.APIURL
if cfg.APIURL != "" {
apiUrl = cfg.APIURL
}
client := llm.NewClient(a.APIKey, apiUrl, "1m")
return &ClassifierFilter{
name: cfg.Name,
model: model,
description: cfg.Description,
client: client,
allowOnMatch: cfg.AllowOnMatch,
isTrigger: cfg.IsTrigger,
}, nil
}
const fmtT = `
Does the below message fit the description "%s"
%s
`
func (f *ClassifierFilter) Name() string { return f.name }
func (f *ClassifierFilter) Apply(job *types.Job) (bool, error) {
input := extractInputFromJob(job)
guidance := fmt.Sprintf(fmtT, f.description, input)
var result struct {
Asserted bool `json:"answer"`
}
err := llm.GenerateTypedJSON(job.GetContext(), f.client, guidance, f.model, jsonschema.Definition{
Type: jsonschema.Object,
Properties: map[string]jsonschema.Definition{
"answer": {
Type: jsonschema.Boolean,
Description: "The answer to the first question",
},
},
Required: []string{"answer"},
}, &result)
if err != nil {
return false, err
}
if result.Asserted {
return f.allowOnMatch, nil
}
return !f.allowOnMatch, nil
}
func (f *ClassifierFilter) IsTrigger() bool {
return f.isTrigger
}
func ClassifierFilterConfigMeta() config.FieldGroup {
return config.FieldGroup{
Name: FilterClassifier,
Label: "Classifier Filter/Trigger",
Fields: []config.Field{
{Name: "name", Label: "Name", Type: "text", Required: true},
{Name: "model", Label: "Model", Type: "text", Required: false,
HelpText: "The LLM to use, usually a smaller one. Leave blank to use the same as the agent's"},
{Name: "api_url", Label: "API URL", Type: "url", Required: false,
HelpText: "The URL of the LLM service if different from the agent's"},
{Name: "description", Label: "Description", Type: "text", Required: true,
HelpText: "Describe the type of content to match against e.g. 'technical support request'"},
{Name: "allow_on_match", Label: "Allow on Match", Type: "checkbox", Required: true},
{Name: "is_trigger", Label: "Is Trigger", Type: "checkbox", Required: true},
},
}
}

86
services/filters/regex.go Normal file
View File

@@ -0,0 +1,86 @@
package filters
import (
"encoding/json"
"regexp"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
)
const FilterRegex = "regex"
type RegexFilter struct {
name string
pattern *regexp.Regexp
allowOnMatch bool
isTrigger bool
}
type RegexFilterConfig struct {
Name string `json:"name"`
Pattern string `json:"pattern"`
AllowOnMatch bool `json:"allow_on_match"`
IsTrigger bool `json:"is_trigger"`
}
func NewRegexFilter(configJSON string) (*RegexFilter, error) {
var cfg RegexFilterConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
return nil, err
}
re, err := regexp.Compile(cfg.Pattern)
if err != nil {
return nil, err
}
return &RegexFilter{
name: cfg.Name,
pattern: re,
allowOnMatch: cfg.AllowOnMatch,
isTrigger: cfg.IsTrigger,
}, nil
}
func (f *RegexFilter) Name() string { return f.name }
func (f *RegexFilter) Apply(job *types.Job) (bool, error) {
input := extractInputFromJob(job)
if f.pattern.MatchString(input) {
return f.allowOnMatch, nil
}
return !f.allowOnMatch, nil
}
func (f *RegexFilter) IsTrigger() bool {
return f.isTrigger
}
func RegexFilterConfigMeta() config.FieldGroup {
return config.FieldGroup{
Name: FilterRegex,
Label: "Regex Filter/Trigger",
Fields: []config.Field{
{Name: "name", Label: "Name", Type: "text", Required: true},
{Name: "pattern", Label: "Pattern", Type: "text", Required: true},
{Name: "allow_on_match", Label: "Allow on Match", Type: "checkbox", Required: true},
{Name: "is_trigger", Label: "Is Trigger", Type: "checkbox", Required: true},
},
}
}
// extractInputFromJob attempts to extract a string input for filtering.
func extractInputFromJob(job *types.Job) string {
if job.Metadata != nil {
if v, ok := job.Metadata["input"]; ok {
if s, ok := v.(string); ok {
return s
}
}
}
// fallback: try to use conversation history if available
if len(job.ConversationHistory) > 0 {
// Use the last message content
last := job.ConversationHistory[len(job.ConversationHistory)-1]
return last.Content
}
return ""
}

View File

@@ -644,6 +644,7 @@ func (a *App) GetAgentConfigMeta() func(c *fiber.Ctx) error {
services.ActionsConfigMeta(), services.ActionsConfigMeta(),
services.ConnectorsConfigMeta(), services.ConnectorsConfigMeta(),
services.DynamicPromptsConfigMeta(), services.DynamicPromptsConfigMeta(),
services.FiltersConfigMeta(),
) )
return c.JSON(configMeta) return c.JSON(configMeta)
} }

View File

@@ -11,6 +11,7 @@ import ModelSettingsSection from './agent-form-sections/ModelSettingsSection';
import PromptsGoalsSection from './agent-form-sections/PromptsGoalsSection'; import PromptsGoalsSection from './agent-form-sections/PromptsGoalsSection';
import AdvancedSettingsSection from './agent-form-sections/AdvancedSettingsSection'; import AdvancedSettingsSection from './agent-form-sections/AdvancedSettingsSection';
import ExportSection from './agent-form-sections/ExportSection'; import ExportSection from './agent-form-sections/ExportSection';
import FiltersSection from './agent-form-sections/FiltersSection';
const AgentForm = ({ const AgentForm = ({
isEdit = false, isEdit = false,
@@ -189,6 +190,13 @@ const AgentForm = ({
<i className="fas fa-plug"></i> <i className="fas fa-plug"></i>
Connectors Connectors
</li> </li>
<li
className={`wizard-nav-item ${activeSection === 'filters-section' ? 'active' : ''}`}
onClick={() => handleSectionChange('filters-section')}
>
<i className="fas fa-shield"></i>
Filters &amp; Triggers
</li>
<li <li
className={`wizard-nav-item ${activeSection === 'actions-section' ? 'active' : ''}`} className={`wizard-nav-item ${activeSection === 'actions-section' ? 'active' : ''}`}
onClick={() => handleSectionChange('actions-section')} onClick={() => handleSectionChange('actions-section')}
@@ -255,6 +263,10 @@ const AgentForm = ({
<ConnectorsSection formData={formData} handleAddConnector={handleAddConnector} handleRemoveConnector={handleRemoveConnector} handleConnectorChange={handleConnectorChange} metadata={metadata} /> <ConnectorsSection formData={formData} handleAddConnector={handleAddConnector} handleRemoveConnector={handleRemoveConnector} handleConnectorChange={handleConnectorChange} metadata={metadata} />
</div> </div>
<div style={{ display: activeSection === 'filters-section' ? 'block' : 'none' }}>
<FiltersSection formData={formData} setFormData={setFormData} metadata={metadata} />
</div>
<div style={{ display: activeSection === 'actions-section' ? 'block' : 'none' }}> <div style={{ display: activeSection === 'actions-section' ? 'block' : 'none' }}>
<ActionsSection formData={formData} setFormData={setFormData} metadata={metadata} /> <ActionsSection formData={formData} setFormData={setFormData} metadata={metadata} />
</div> </div>
@@ -306,6 +318,10 @@ const AgentForm = ({
<ConnectorsSection formData={formData} handleAddConnector={handleAddConnector} handleRemoveConnector={handleRemoveConnector} handleConnectorChange={handleConnectorChange} metadata={metadata} /> <ConnectorsSection formData={formData} handleAddConnector={handleAddConnector} handleRemoveConnector={handleRemoveConnector} handleConnectorChange={handleConnectorChange} metadata={metadata} />
</div> </div>
<div style={{ display: activeSection === 'filters-section' ? 'block' : 'none' }}>
<FiltersSection formData={formData} setFormData={setFormData} metadata={metadata} />
</div>
<div style={{ display: activeSection === 'actions-section' ? 'block' : 'none' }}> <div style={{ display: activeSection === 'actions-section' ? 'block' : 'none' }}>
<ActionsSection formData={formData} setFormData={setFormData} metadata={metadata} /> <ActionsSection formData={formData} setFormData={setFormData} metadata={metadata} />
</div> </div>

View File

@@ -13,6 +13,7 @@ import FormFieldDefinition from './common/FormFieldDefinition';
* @param {String} props.itemType - Type of items being configured ('action', 'connector', etc.) * @param {String} props.itemType - Type of items being configured ('action', 'connector', etc.)
* @param {String} props.typeField - The field name that determines the item's type (e.g., 'name' for actions, 'type' for connectors) * @param {String} props.typeField - The field name that determines the item's type (e.g., 'name' for actions, 'type' for connectors)
* @param {String} props.addButtonText - Text for the add button * @param {String} props.addButtonText - Text for the add button
* @param {String} props.saveAllFieldsAsString - Whether to save all fields as string or the appropriate JSON type
*/ */
const ConfigForm = ({ const ConfigForm = ({
items = [], items = [],
@@ -22,7 +23,8 @@ const ConfigForm = ({
onAdd, onAdd,
itemType = 'item', itemType = 'item',
typeField = 'type', typeField = 'type',
addButtonText = 'Add Item' addButtonText = 'Add Item',
saveAllFieldsAsString = true,
}) => { }) => {
// Generate options from fieldGroups // Generate options from fieldGroups
const typeOptions = [ const typeOptions = [
@@ -62,8 +64,10 @@ const ConfigForm = ({
const item = items[index]; const item = items[index];
const config = parseConfig(item); const config = parseConfig(item);
if (type === 'checkbox') if (type === 'number' && !saveAllFieldsAsString)
config[key] = checked ? 'true' : 'false'; config[key] = Number(value);
else if (type === 'checkbox')
config[key] = saveAllFieldsAsString ? (checked ? 'true' : 'false') : checked;
else else
config[key] = value; config[key] = value;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import ConfigForm from './ConfigForm';
/**
* FilterForm component for configuring an filter
* Renders filter configuration forms based on field group metadata
*/
const FilterForm = ({ filters = [], onChange, onRemove, onAdd, fieldGroups = [] }) => {
const handleFilterChange = (index, updatedFilter) => {
onChange(index, updatedFilter);
};
return (
<ConfigForm
items={filters}
fieldGroups={fieldGroups}
onChange={handleFilterChange}
onRemove={onRemove}
onAdd={onAdd}
itemType="filter"
addButtonText="Add Filter"
saveAllFieldsAsString={false}
/>
);
};
export default FilterForm;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import FilterForm from '../FilterForm';
/**
* FiltersSection component for the agent form
*/
const FiltersSection = ({ formData, setFormData, metadata }) => {
// Handle filter change
const handleFilterChange = (index, updatedFilter) => {
const updatedFilters = [...(formData.filters || [])];
updatedFilters[index] = updatedFilter;
setFormData({
...formData,
filters: updatedFilters
});
};
// Handle filter removal
const handleFilterRemove = (index) => {
const updatedFilters = [...(formData.filters || [])].filter((_, i) => i !== index);
setFormData({
...formData,
filters: updatedFilters
});
};
// Handle adding an filter
const handleAddFilter = () => {
setFormData({
...formData,
filters: [
...(formData.filters || []),
{ name: '', config: '{}' }
]
});
};
return (
<div className="filters-section">
<h3>Filters</h3>
<p className="text-muted">
Jobs received by the agent must pass all filters and at least one trigger (if any are specified)
</p>
<FilterForm
filters={formData.filters || []}
onChange={handleFilterChange}
onRemove={handleFilterRemove}
onAdd={handleAddFilter}
fieldGroups={metadata?.filters || []}
/>
</div>
);
};
export default FiltersSection;

View File

@@ -86,9 +86,22 @@ function ObservableSummary({ observable }) {
completionError = `Error: ${completion.error}`; completionError = `Error: ${completion.error}`;
} }
let completionFilter = '';
if (completion?.filter_result) {
if (completion.filter_result?.has_triggers && !completion.filter_result?.triggered_by) {
completionFilter = 'Failed to match any triggers';
} else if (completion.filter_result?.triggered_by) {
completionFilter = `Triggered by ${completion.filter_result.triggered_by}`;
}
if (completion?.filter_result?.failed_by)
completionFilter = `${completionFilter ? completionFilter + ', ' : ''}Failed by ${completion.filter_result.failed_by}`;
}
// Only show if any summary is present // Only show if any summary is present
if (!creationChatMsg && !creationFunctionDef && !creationFunctionParams && if (!creationChatMsg && !creationFunctionDef && !creationFunctionParams &&
!completionChatMsg && !completionConversation && !completionActionResult && !completionAgentState && !completionError) { !completionChatMsg && !completionConversation && !completionActionResult &&
!completionAgentState && !completionError && !completionFilter) {
return null; return null;
} }
@@ -172,6 +185,12 @@ function ObservableSummary({ observable }) {
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{completionError}</span> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{completionError}</span>
</div> </div>
)} )}
{completionFilter && (
<div title={completionFilter} style={{ display: 'flex', alignItems: 'center', color: '#ffd7', fontSize: 14 }}>
<i className="fas fa-shield-alt" style={{ marginRight: 6, flex: '0 0 auto' }}></i>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{completionFilter}</span>
</div>
)}
</div> </div>
); );
} }

View File

@@ -121,6 +121,7 @@ export const agentApi = {
groupedMetadata.actions = metadata.Actions; groupedMetadata.actions = metadata.Actions;
} }
groupedMetadata.dynamicPrompts = metadata.DynamicPrompts; groupedMetadata.dynamicPrompts = metadata.DynamicPrompts;
groupedMetadata.filters = metadata.Filters;
return groupedMetadata; return groupedMetadata;
} }