feat(filters): Add configurable filters for incoming jobs
Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
@@ -73,6 +73,8 @@ Still having issues? see this Youtube video: https://youtu.be/HtVwIxW3ePg
|
|||||||
|
|
||||||
[](https://youtu.be/HtVwIxW3ePg)
|
[](https://youtu.be/HtVwIxW3ePg)
|
||||||
[](https://youtu.be/v82rswGJt_M)
|
[](https://youtu.be/v82rswGJt_M)
|
||||||
|
[](https://youtu.be/d_we-AYksSw)
|
||||||
|
|
||||||
|
|
||||||
## 📚🆕 Local Stack Family
|
## 📚🆕 Local Stack Family
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
15
core/types/filters.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
1
main.go
1
main.go
@@ -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
44
services/filters.go
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
121
services/filters/classifier.go
Normal file
121
services/filters/classifier.go
Normal 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
86
services/filters/regex.go
Normal 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 ""
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
27
webui/react-ui/src/components/FilterForm.jsx
Normal file
27
webui/react-ui/src/components/FilterForm.jsx
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1
webui/react-ui/src/utils/api.js
vendored
1
webui/react-ui/src/utils/api.js
vendored
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user