feat(studio): enforce the action policy of unattended runs

A trigger run or an agent job has nobody to approve a step, and so far
the approval mode auto_readonly and the act.* grants were only recorded.
The prompt was the only thing that kept such a run from acting.

Every tool call of an unattended run now goes through one check at the
call. A tool that only reads runs. Any other tool needs the grant of its
class, and the check fails closed:

- act.files for write_file, edit_file, move_file, delete_file and the
  built-in bash;
- act.addons, or act.addons:<id>, for add-on tools whose declared kind is
  not read, plus act.spend when the add-on gates the tool with a
  permission;
- act.spend for MCP tools.

A trigger holds its new acts list within the grants of its agent. A
trigger that declared read only runs, or that a binding in auto_readonly
mode made without asking, is refused every non-read tool whatever it
holds. A binding that cannot be read counts as auto_readonly. A job
holds the act grants of its agent. Sub-agents follow their parent, and a
saved agent cannot be started from an unattended run.

A refused call answers with a plain sentence the model can pass on, not
an error, and writes one audit log line. Triggers set acts through the
REST API; a model never can. A trigger whose plan names a file it writes
keeps act.files.

Assisted-by: Claude:claude-sonnet-5-5 go ginkgo swag
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 19:12:29 +00:00
1 parent 3cef8d5a91
commit 6b181cd20b
22 files changed
+760 -9

No files matched your search

+5 -1
View File
@@ -41,7 +41,8 @@ func triggerOut(ctx context.Context, host *studiosvc.Triggers, t triggers.Trigge
Folder: host.RelFolder(t), QuietSeconds: int(t.Quiet / time.Second), DailyCap: t.CapOrDefault(), Enabled: t.Enabled,
AgentID: t.AgentID, Model: t.Model, SourceChatID: t.SourceChatID, Summary: host.Summary(t),
Plan: schema.ChatsTriggerPlan{Reads: t.Reads, Writes: t.Writes, Widget: t.Widget},
Cost: schema.ChatsTriggerCost{EstimatedTokens: t.EstTokens, MaxRunsPerDay: host.MaxRunsPerDay(t), Remote: host.RemoteModel(t.Model)}}
Cost: schema.ChatsTriggerCost{EstimatedTokens: t.EstTokens, MaxRunsPerDay: host.MaxRunsPerDay(t), Remote: host.RemoteModel(t.Model)},
Acts: t.Acts}
if t.Kind == triggers.KindFile {
out.MinGapSeconds = int(t.GapOrDefault() / time.Second)
}
@@ -75,6 +76,9 @@ func triggerIn(req schema.ChatsTriggerWrite) studiosvc.TriggerInput {
Folder: req.Folder, Quiet: time.Duration(req.QuietSeconds) * time.Second, DailyCap: req.DailyCap,
AgentID: req.AgentID, Model: req.Model, SourceChatID: req.SourceChatID, Enabled: req.Enabled}
in.MinGap, in.EstTokens = time.Duration(req.MinGapSeconds)*time.Second, req.EstimatedTokens
if req.Acts != nil {
in.Acts = req.Acts
}
if req.Plan != nil {
in.Reads, in.Writes, in.Widget = req.Plan.Reads, req.Plan.Writes, req.Plan.Widget
}
+8
View File
@@ -1052,6 +1052,10 @@ type ChatsTrigger struct {
// file it writes; Cost what a run costs.
Plan ChatsTriggerPlan `json:"plan"`
Cost ChatsTriggerCost `json:"cost"`
// Acts are the action classes that a run with nobody present may use:
// act.files, act.addons, act.addons:<id>, act.send, act.spend. A run
// without any only reads.
Acts []string `json:"acts,omitempty"`
}
// ChatsTriggerProposal is a trigger a chat proposed and what became of it.
@@ -1143,6 +1147,10 @@ type ChatsTriggerWrite struct {
// EstimatedTokens is about how many tokens one run uses; 0 keeps the
// trigger's value.
EstimatedTokens int `json:"estimated_tokens,omitempty"`
// Acts are the action classes that a run with nobody present may use
// (act.files, act.addons, act.addons:<id>, act.send, act.spend). Leave it
// out to keep the trigger's value; an empty list takes all of them away.
Acts []string `json:"acts,omitempty"`
}
// ChatsTriggerList is the response of the trigger list endpoint.
+168
View File
@@ -0,0 +1,168 @@
package studio
import (
"context"
"encoding/json"
"slices"
"strings"
"github.com/mudler/LocalAI/core/services/studio/builtins"
"github.com/mudler/LocalAI/core/services/studio/connectors"
"github.com/mudler/LocalAI/core/services/studio/session"
)
// The action policy of an unattended run (a trigger or an agent job). The
// rules are in builtins.ActPolicy. This file finds the rights of a run, wraps
// the acting tools of the chat with the check, and writes the audit line.
//
// A tool that the policy refuses stays in the tool list. Its call returns a
// plain sentence instead of running, so the model can tell the person what it
// wanted to do. The refusal is a result, not an error: an error would make
// the model retry.
// unattended says whether a chat started by itself: no person watches it and
// nobody can approve a step.
func unattended(origin string) bool {
return origin == session.OriginTrigger || origin == session.OriginJob
}
// actPolicy gives the rights of an unattended run, or false for a chat that a
// person watches. It fails closed: a trigger or binding that cannot be read
// gives a read only policy.
//
// - Trigger run: the act.* names of the trigger (Acts), within the grants of
// its agent. A trigger without an agent has no ceiling but its own list. A
// trigger whose plan names a file it writes (Writes) holds act.files, as
// the owner saw it on the proposal card.
// - Read only: the trigger declared that its runs only read, or it was made
// without asking by a binding in the auto_readonly mode.
// - Job run: jobs have no list of their own, so the run holds the act.*
// grants of its agent, and nothing without one.
func (s *Service) actPolicy(ctx context.Context, rec session.ChatRecord) (builtins.ActPolicy, bool) {
if !unattended(rec.Origin) {
return builtins.ActPolicy{}, false
}
ceiling := actNames(s.chatGrants(ctx, rec))
switch rec.Origin {
case session.OriginJob:
return builtins.ActPolicy{Held: ceiling}, true
}
if s.triggers == nil {
return builtins.ActPolicy{ReadOnly: true}, true
}
t, err := s.triggers.o.Store.GetTrigger(ctx, rec.OriginID)
if err != nil || t.Owner != rec.Principal {
return builtins.ActPolicy{ReadOnly: true}, true
}
want := slices.Clone(t.Acts)
if t.Writes != "" && !slices.Contains(want, builtins.ClassFiles) {
want = append(want, builtins.ClassFiles)
}
pol := builtins.ActPolicy{ReadOnly: t.Route.ReadOnly}
if t.Route.Auto && s.bindingMode(ctx, t.Route.Binding) == connectors.ApprovalAutoReadonly {
pol.ReadOnly = true
}
for _, n := range want {
if rec.AgentID == "" || withinCeiling(n, ceiling) {
pol.Held = append(pol.Held, n)
}
}
return pol, true
}
// bindingMode is the approval mode of a binding. A binding that cannot be read
// is treated as auto_readonly: the host does not widen a run on a doubt.
func (s *Service) bindingMode(ctx context.Context, id string) string {
if id == "" || s.connectors == nil {
return connectors.ApprovalAutoReadonly
}
b, err := s.connectors.o.Store.GetBinding(ctx, id)
if err != nil {
return connectors.ApprovalAutoReadonly
}
return b.Approval.ModeOrDefault()
}
// actNames keeps the act.* names of a list of grants.
func actNames(grants []string) []string {
var out []string
for _, g := range grants {
if slices.Contains(builtins.ActGrants(), g) {
out = append(out, g)
}
}
return out
}
// withinCeiling says whether the agent's grants cover a name of a trigger: the
// name itself, or act.addons for act.addons:<id>.
func withinCeiling(name string, ceiling []string) bool {
if slices.Contains(ceiling, name) {
return true
}
return strings.HasPrefix(name, builtins.ClassAddons+":") && slices.Contains(ceiling, builtins.ClassAddons)
}
// gateAct wraps a tool with the policy. The policy is checked at each call, so
// a prompt cannot change the answer.
func (s *Service) gateAct(rec session.ChatRecord, pol builtins.ActPolicy, t session.Tool, req builtins.ActTool) session.Tool {
if req.Name == "" {
req.Name = t.Name
}
run := t.Run
t.Run = func(ctx context.Context, args json.RawMessage) (string, error) {
v := pol.Decide(req)
if !req.Read {
s.log.Info("studio: unattended action", "chat", rec.ID, "origin", rec.Origin, "origin_id", rec.OriginID,
"tool", req.Name, "allowed", v.Allowed, "reason", v.Reason, "missing", v.Missing)
}
if !v.Allowed {
return v.Sentence, nil
}
return run(ctx, args)
}
return t
}
// gateAddonAct applies the policy to the tool of an add-on. The kind that the
// add-on declared decides read or action, and a permission means the tool
// acts outside LocalAI with a credential.
func (s *Service) gateAddonAct(rec session.ChatRecord, pol builtins.ActPolicy, t session.Tool, d AddonTool) session.Tool {
return s.gateAct(rec, pol, t, builtins.ActTool{Name: t.Name, Read: !d.Action, Class: builtins.ClassAddons,
AddonID: d.AddonID, External: d.Permission != ""})
}
// gateExternalAct applies the policy to tools that come from outside, such as
// MCP servers: they are paid or external, so they need act.spend.
func (s *Service) gateExternalAct(ctx context.Context, rec session.ChatRecord, tools []session.Tool) []session.Tool {
pol, ok := s.actPolicy(ctx, rec)
if !ok {
return tools
}
out := make([]session.Tool, len(tools))
for i, t := range tools {
out[i] = s.gateAct(rec, pol, t, builtins.ActTool{Name: t.Name, Class: builtins.ClassSpend})
}
return out
}
// gateBuiltinActs applies the policy to the built-in tools that change files
// or run commands. The other built-in tools read, or have their own grant.
func (s *Service) gateBuiltinActs(ctx context.Context, rec session.ChatRecord, tools []session.Tool) []session.Tool {
pol, ok := s.actPolicy(ctx, rec)
if !ok {
return tools
}
out := make([]session.Tool, len(tools))
for i, t := range tools {
out[i] = t
switch {
case fileWriteTools[t.Name]:
out[i] = s.gateAct(rec, pol, t, builtins.ActTool{Name: t.Name, Class: builtins.ClassFiles})
case t.Name == commandTool && t.Provider == BuiltinProvider:
// Commands on this machine reach the files and the network.
out[i] = s.gateAct(rec, pol, t, builtins.ActTool{Name: t.Name, Class: builtins.ClassFiles, External: true})
}
}
return out
}
+158
View File
@@ -0,0 +1,158 @@
package studio_test
import (
"bytes"
"context"
"log/slog"
"strings"
"sync"
"github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/agents"
"github.com/mudler/LocalAI/core/services/studio/connectors"
"github.com/mudler/LocalAI/core/services/studio/studiotest"
"github.com/mudler/LocalAI/core/services/studio/triggers"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/tests/studiobot"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// syncBuffer is a log sink that the service and the spec can share.
type syncBuffer struct {
mu sync.Mutex
b bytes.Buffer
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuffer) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.String()
}
var _ = Describe("the action policy of an unattended run", func() {
ctx := context.Background()
const refused = "I did not run write_file"
var (
r *connRig
b connectors.Binding
logs *syncBuffer
)
// The model of every run reads the folder, then writes a file, then
// reports. The spec reads what each tool answered.
script := func(_ workspace.Actor, text string) []studiotest.ToolStep {
if strings.Contains(text, "WRITE") {
return []studiotest.ToolStep{
{Tool: "list_files", Args: `{"path":"/"}`},
{Tool: "write_file", Args: `{"path":"/report.txt","content":"x"}`},
{Say: "finished"},
}
}
return []studiotest.ToolStep{{Say: "ok"}}
}
// setup binds a connection with the approval under test to a saved agent
// that holds the given grants.
setup := func(ap connectors.Approval, grants ...string) {
logs = &syncBuffer{}
sh := newShared()
sh.deps = func(d *studio.Deps) { d.Logger = slog.New(slog.NewTextHandler(logs, nil)) }
tr := studiobot.NewTransport(studiobot.Addon{ID: "chat-addon", Kind: contract.ConnectorKind{ID: "chatty", Title: "Chatty", Delivery: contract.DeliveryPull}, Runtime: &postAddon{}})
r = newConnRigOpts(sh, connStore(), tr, script, nil, nil)
ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Worker", Model: "m", Builtins: grants})
Expect(err).NotTo(HaveOccurred())
r.agent = ag
gate := &capSwitch{}
gate.set(studio.CapTriggersRun, true)
r.svc.SetCapabilityGate(gate)
b = r.bind(studio.ConnectorInput{Kind: "chatty", Token: "tok", Allow: []string{"11"}, Approval: &ap})
}
// run makes a trigger of the binding, lets it run once and returns what
// write_file answered.
run := func(in studio.TriggerInput) (out string, err error) {
in.Prompt, in.AgentID = "WRITE the report", r.agent.ID
tr := r.schedule(in)
r.due()
end := r.waitEnded(tr.ID)
Expect(end.Status).To(Equal(triggers.StatusDone), "run = %+v", end)
rs := r.h.ResultsFor("write_file")
Expect(rs).To(HaveLen(1), "results = %+v", rs)
Expect(r.h.ResultsFor("list_files")).To(HaveLen(1))
Expect(r.h.ResultsFor("list_files")[0].Out).NotTo(ContainSubstring("I did not run"), "a read always runs")
return rs[0].Out, rs[0].Err
}
It("refuses every non-read tool of a trigger made without asking by an auto_readonly binding, whatever it holds", func() {
setup(connectors.Approval{Mode: connectors.ApprovalAutoReadonly}, "act.files")
out, err := run(studio.TriggerInput{Acts: []string{"act.files"}, Route: triggers.Route{Binding: b.ID, Auto: true}})
Expect(err).NotTo(HaveOccurred(), "a refusal is an answer, not an error")
Expect(out).To(ContainSubstring(refused))
Expect(out).To(ContainSubstring("read and report only"))
Expect(logs.String()).To(And(ContainSubstring("unattended action"), ContainSubstring("tool=write_file"), ContainSubstring("allowed=false"), ContainSubstring("reason=read_only")))
})
It("treats a binding it cannot read as auto_readonly", func() {
setup(connectors.Approval{}, "act.files")
out, _ := run(studio.TriggerInput{Acts: []string{"act.files"}, Route: triggers.Route{Binding: "gone", Auto: true}})
Expect(out).To(ContainSubstring(refused))
})
It("refuses a trigger that declared its runs only read, even when it holds act.files", func() {
setup(connectors.Approval{}, "act.files")
out, _ := run(studio.TriggerInput{Acts: []string{"act.files"}, Route: triggers.Route{Binding: b.ID, ReadOnly: true}})
Expect(out).To(ContainSubstring(refused))
})
It("starts read only: a trigger with no acts cannot write, and the refusal names the missing grant", func() {
setup(connectors.Approval{}, "act.files")
out, _ := run(studio.TriggerInput{Route: triggers.Route{Binding: b.ID}})
Expect(out).To(ContainSubstring(refused))
Expect(out).To(ContainSubstring("act.files"))
Expect(logs.String()).To(And(ContainSubstring("allowed=false"), ContainSubstring("reason=not_granted"), ContainSubstring("missing=act.files")))
})
It("lets a trigger write when the owner gave it act.files and the agent allows it", func() {
setup(connectors.Approval{}, "act.files")
out, err := run(studio.TriggerInput{Acts: []string{"act.files"}, Route: triggers.Route{Binding: b.ID}})
Expect(err).NotTo(HaveOccurred())
Expect(out).NotTo(ContainSubstring("I did not run"))
Expect(logs.String()).To(And(ContainSubstring("tool=write_file"), ContainSubstring("allowed=true"), ContainSubstring("reason=granted")))
})
It("keeps the agent's grants as the ceiling of the trigger's acts", func() {
setup(connectors.Approval{} /* the agent holds no act.* grant */)
out, _ := run(studio.TriggerInput{Acts: []string{"act.files"}, Route: triggers.Route{Binding: b.ID}})
Expect(out).To(ContainSubstring(refused))
})
It("lets a trigger write the file its plan names, as the owner saw on the card", func() {
setup(connectors.Approval{}, "act.files")
out, err := run(studio.TriggerInput{Writes: "report.txt", Route: triggers.Route{Binding: b.ID}})
Expect(err).NotTo(HaveOccurred())
Expect(out).NotTo(ContainSubstring("I did not run"))
})
It("keeps acts on an edit that leaves them out, clears them on an empty list, and refuses an unknown one", func() {
setup(connectors.Approval{}, "act.files")
tr := r.schedule(studio.TriggerInput{Prompt: "WRITE", AgentID: r.agent.ID, Acts: []string{"act.files"}})
Expect(tr.Acts).To(Equal([]string{"act.files"}))
edit := func(acts []string) (triggers.Trigger, error) {
return r.host.UpdateTrigger(ctx, alice, tr.ID, studio.TriggerInput{Name: tr.Name, Prompt: tr.Prompt, Kind: tr.Kind,
Schedule: tr.Schedule, TZ: tr.TZ, Model: tr.Model, AgentID: tr.AgentID, Enabled: true, Acts: acts})
}
up, err := edit(nil)
Expect(err).NotTo(HaveOccurred())
Expect(up.Acts).To(Equal([]string{"act.files"}))
up, err = edit([]string{})
Expect(err).NotTo(HaveOccurred())
Expect(up.Acts).To(BeEmpty())
_, err = edit([]string{"act.nothing"})
Expect(err).To(HaveOccurred())
})
})
+7
View File
@@ -209,6 +209,7 @@ func (s *Service) addonTools(ctx context.Context, a workspace.Actor, rec session
}
}
var tools []session.Tool
pol, guarded := s.actPolicy(ctx, rec)
gate, _ := s.d.AddonTools.(AddonGate)
reasons := map[string]string{}
for _, d := range defs {
@@ -241,6 +242,9 @@ func (s *Service) addonTools(ctx context.Context, a workspace.Actor, rec session
schema = json.RawMessage(`{"type":"object"}`)
}
if t, ok := s.shadowTool(a, d, schema); ok {
if guarded {
t = s.gateAddonAct(rec, pol, t, d)
}
tools = append(tools, t)
continue
}
@@ -258,6 +262,9 @@ func (s *Service) addonTools(ctx context.Context, a workspace.Actor, rec session
continue
}
}
if guarded {
tool = s.gateAddonAct(rec, pol, tool, d)
}
tools = append(tools, tool)
}
installed := map[string]string{}
+143
View File
@@ -0,0 +1,143 @@
package builtins
import (
"regexp"
"slices"
"strings"
)
// The action policy of an unattended run: a trigger or an agent job, which
// runs with no person at the screen. A run like that starts read only. It may
// do more only for the classes of action that the owner granted (the act.*
// grants), and a run that the owner fixed as read only may do nothing but
// read, whatever it was granted.
//
// The policy is a pure function of the tool being called and the run's
// rights, so every tool of every source goes through the same table and the
// answer does not depend on the prompt.
// The action classes. A tool that only reads has none.
const (
// ClassFiles is a write inside the fence of the run's folders.
ClassFiles = GrantActFiles
// ClassAddons is an action tool of an add-on (any kind but read).
ClassAddons = GrantActAddons
// ClassSend is a message beyond the delivery target of the run.
ClassSend = GrantActSend
// ClassSpend is a paid or external tool: the network, an MCP server, or an
// add-on tool that acts with the person's credential outside LocalAI.
ClassSpend = GrantActSpend
)
// addonGrantRE is the shape of a grant of one add-on: act.addons:<id>.
var addonGrantRE = regexp.MustCompile(`^act\.addons:[a-z0-9][a-z0-9._-]{0,63}$`)
// ActGrants lists the act.* names in declaration order.
func ActGrants() []string {
return []string{ClassFiles, ClassAddons, ClassSend, ClassSpend}
}
// ValidActGrant says whether name is an act.* grant that a trigger may carry:
// one of the four classes, or act.addons:<id> for one add-on.
func ValidActGrant(name string) bool {
return slices.Contains(ActGrants(), name) || addonGrantRE.MatchString(name)
}
// AddonActGrant is the grant that allows the action tools of one add-on.
func AddonActGrant(addonID string) string { return ClassAddons + ":" + addonID }
// ActTool is what the policy needs to know of a tool call.
type ActTool struct {
// Name is the tool as the model sees it.
Name string
// Read is true for a tool that only reads. It is the add-on's declared
// kind (contract.ToolKindRead) for an add-on tool.
Read bool
// Class is the class of an acting tool: one of the Class constants.
Class string
// AddonID names the add-on of an add-on tool, for act.addons:<id>.
AddonID string
// External is true for an acting tool that reaches outside LocalAI with a
// credential or the network; it needs act.spend as well as its class.
External bool
}
// ActPolicy is the rights of one unattended run.
type ActPolicy struct {
// ReadOnly says that the owner fixed the run to read and report: the
// binding that made it is in the auto_readonly mode, or the run declared
// that it only reads. No grant overrides it.
ReadOnly bool
// Held are the act.* grants the run holds: what the owner gave the
// trigger, within what the agent's grants allow.
Held []string
}
// ActVerdict is the answer of the policy.
type ActVerdict struct {
Allowed bool
// Missing is the first grant the run lacks. Empty when allowed, and for a
// read only run.
Missing string
// Reason is a short code for the audit line: read, granted, read_only,
// not_granted.
Reason string
// Sentence is what the model is told when the call is refused. It is a
// plain sentence the model can pass on to the person.
Sentence string
}
// holds says whether the run holds need. act.addons:<id> is satisfied by
// act.addons too.
func (p ActPolicy) holds(need string) bool {
if slices.Contains(p.Held, need) {
return true
}
if id, ok := strings.CutPrefix(need, ClassAddons+":"); ok && id != "" {
return slices.Contains(p.Held, ClassAddons)
}
return false
}
// Decide checks one tool call. A tool that reads is always allowed. Any other
// is refused unless the run is not read only and holds every grant the tool
// needs; a tool with no class is refused, never assumed harmless.
func (p ActPolicy) Decide(t ActTool) ActVerdict {
if t.Read {
return ActVerdict{Allowed: true, Reason: "read"}
}
if p.ReadOnly {
return ActVerdict{Reason: "read_only", Sentence: "I did not run " + t.Name + ": this run was set up to read and report only, so it cannot " +
"change anything. Say in your answer what you wanted to do, and leave it to the owner."}
}
needs := t.needs()
for _, n := range needs {
if !p.holds(n) {
return ActVerdict{Missing: n, Reason: "not_granted", Sentence: "I did not run " + t.Name + ": nobody is here to approve it and this run " +
"has no permission for " + n + ". Say in your answer what you wanted to do; the owner can allow " + n + " for this run."}
}
}
return ActVerdict{Allowed: true, Reason: "granted"}
}
// needs lists the grants a tool call requires. A tool with an unknown class
// needs one nobody holds, so it is refused.
func (t ActTool) needs() []string {
var out []string
switch t.Class {
case ClassAddons:
if t.AddonID != "" {
out = append(out, AddonActGrant(t.AddonID))
} else {
out = append(out, ClassAddons)
}
case ClassFiles, ClassSend, ClassSpend:
out = append(out, t.Class)
default:
return []string{"act.unclassified"}
}
if t.External && t.Class != ClassSpend {
out = append(out, ClassSpend)
}
return out
}
@@ -0,0 +1,65 @@
package builtins_test
import (
"github.com/mudler/LocalAI/core/services/studio/builtins"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("the action policy of an unattended run", func() {
read := builtins.ActTool{Name: "list", Read: true, Class: builtins.ClassAddons, AddonID: "notes"}
write := builtins.ActTool{Name: "write_file", Class: builtins.ClassFiles}
addon := builtins.ActTool{Name: "post", Class: builtins.ClassAddons, AddonID: "notes"}
credential := builtins.ActTool{Name: "push", Class: builtins.ClassAddons, AddonID: "git", External: true}
send := builtins.ActTool{Name: "reply", Class: builtins.ClassSend}
spend := builtins.ActTool{Name: "mcp_search", Class: builtins.ClassSpend}
DescribeTable("decides each tool by the mode and the grants",
func(p builtins.ActPolicy, t builtins.ActTool, allowed bool, reason, missing string) {
v := p.Decide(t)
Expect(v.Allowed).To(Equal(allowed))
Expect(v.Reason).To(Equal(reason))
Expect(v.Missing).To(Equal(missing))
if allowed {
Expect(v.Sentence).To(BeEmpty())
} else {
Expect(v.Sentence).To(ContainSubstring("I did not run " + t.Name))
}
},
// A read always runs, even when the run is fixed to read only.
Entry("read, no grants", builtins.ActPolicy{}, read, true, "read", ""),
Entry("read, read only", builtins.ActPolicy{ReadOnly: true}, read, true, "read", ""),
// Deny by default.
Entry("file write, no grants", builtins.ActPolicy{}, write, false, "not_granted", "act.files"),
Entry("add-on action, no grants", builtins.ActPolicy{}, addon, false, "not_granted", "act.addons:notes"),
Entry("send, no grants", builtins.ActPolicy{}, send, false, "not_granted", "act.send"),
Entry("external tool, no grants", builtins.ActPolicy{}, spend, false, "not_granted", "act.spend"),
// Each class needs its own grant.
Entry("file write with act.files", builtins.ActPolicy{Held: []string{"act.files"}}, write, true, "granted", ""),
Entry("file write with act.addons only", builtins.ActPolicy{Held: []string{"act.addons", "act.send", "act.spend"}}, write, false, "not_granted", "act.files"),
Entry("add-on action with act.addons", builtins.ActPolicy{Held: []string{"act.addons"}}, addon, true, "granted", ""),
Entry("add-on action with act.addons for this add-on", builtins.ActPolicy{Held: []string{"act.addons:notes"}}, addon, true, "granted", ""),
Entry("add-on action with act.addons for another add-on", builtins.ActPolicy{Held: []string{"act.addons:other"}}, addon, false, "not_granted", "act.addons:notes"),
Entry("send with act.send", builtins.ActPolicy{Held: []string{"act.send"}}, send, true, "granted", ""),
Entry("external tool with act.spend", builtins.ActPolicy{Held: []string{"act.spend"}}, spend, true, "granted", ""),
// A credentialed add-on action also reaches outside.
Entry("credentialed action with act.addons only", builtins.ActPolicy{Held: []string{"act.addons"}}, credential, false, "not_granted", "act.spend"),
Entry("credentialed action with both", builtins.ActPolicy{Held: []string{"act.addons", "act.spend"}}, credential, true, "granted", ""),
// A read only run refuses every action, whatever it holds.
Entry("read only run with every grant, file write", builtins.ActPolicy{ReadOnly: true, Held: builtins.ActGrants()}, write, false, "read_only", ""),
Entry("read only run with every grant, add-on action", builtins.ActPolicy{ReadOnly: true, Held: builtins.ActGrants()}, addon, false, "read_only", ""),
Entry("read only run with every grant, send", builtins.ActPolicy{ReadOnly: true, Held: builtins.ActGrants()}, send, false, "read_only", ""),
Entry("read only run with every grant, external tool", builtins.ActPolicy{ReadOnly: true, Held: builtins.ActGrants()}, spend, false, "read_only", ""),
// A tool with no class is refused, never assumed harmless.
Entry("unclassified tool with every grant", builtins.ActPolicy{Held: builtins.ActGrants()}, builtins.ActTool{Name: "mystery"}, false, "not_granted", "act.unclassified"),
)
It("names the grants a trigger may carry", func() {
for _, ok := range []string{"act.files", "act.addons", "act.send", "act.spend", "act.addons:notes", "act.addons:git.hub"} {
Expect(builtins.ValidActGrant(ok)).To(BeTrue(), ok)
}
for _, bad := range []string{"", "files", "act.", "act.addons:", "act.addons:Bad Name", "act.other", "memory.write"} {
Expect(builtins.ValidActGrant(bad)).To(BeFalse(), bad)
}
})
})
+2
View File
@@ -55,6 +55,8 @@ func (s *Service) scopedHostTools(ctx context.Context, a workspace.Actor) ([]ses
tools, missing = append(tools, t...), append(missing, m...)
}
t, m = s.mcpTools(ctx, a, rec, sc, tools)
// MCP tools are external: an unattended run needs act.spend for them.
t = s.gateExternalAct(ctx, rec, t)
return append(tools, t...), append(missing, m...), nil
}
+4
View File
@@ -42,6 +42,10 @@ var (
// ErrSavedAgentWithTools means spawn_agent named an agent and tools: the
// agent's definition decides its tools.
ErrSavedAgentWithTools = errors.New("tools cannot be combined with agent: the saved agent's definition decides its tools")
// ErrSavedAgentUnattended means a run that nobody watches (a trigger or a
// job) tried to start a saved agent: that agent would not carry the limits
// of the run.
ErrSavedAgentUnattended = errors.New("a run that nobody watches cannot start a saved agent")
)
const listAgentsDescription = "List the saved agents of the user that you may start as sub-agents: id, name and description, and the folder and mode (read or write) of an agent that works in one. " +
+10
View File
@@ -245,6 +245,9 @@ func (s *Service) chatTools(ctx context.Context, x session.Access) (session.Turn
// load_skill learns from the final list whether the chat can read a
// skill's files and run what a skill needs.
tools = s.fitLoadSkill(a, tools)
// The action policy of a run that nobody watches: the tools that change
// files or run commands on this machine are checked at each call.
tools = s.gateBuiltinActs(ctx, rec, tools)
return session.TurnTools{Tools: tools, Prompt: prompt, Note: note, WorkFolder: work, RefreshKey: key + "\x00" + rec.Settings.SystemPrompt + "\x00" + personaKey + "\x00" + notesKey}, nil
}
@@ -312,6 +315,11 @@ func (s *Service) spawn(ctx context.Context, x session.Access, raw json.RawMessa
if err := s.savedAgentsAllowed(ctx, parent); err != nil {
return "", err
}
if unattended(parent.Origin) {
// The chat of a saved agent has no origin, so it would not carry the
// rights of this run.
return "", ErrSavedAgentUnattended
}
if agent, err = s.resolveAgent(ctx, a.Principal, ref); err != nil {
return "", err
}
@@ -451,6 +459,8 @@ func (s *Service) subset(ctx context.Context, x session.Access, names *[]string)
// Built for a sub-agent: bash on this machine only when no card is
// needed, since nobody watches the sub-agent's chat.
have = s.applyBuiltins(have, s.builtinsFor(ctx, a, parent, true))
// A sub-agent of a run that nobody watches has the rights of that run.
have = s.gateBuiltinActs(ctx, parent, have)
}
if names == nil {
kept := have[:0:0]
+1 -1
View File
@@ -191,7 +191,7 @@ func changedFields(a, b triggers.Trigger) []string {
func (h *Triggers) inputOf(t triggers.Trigger) TriggerInput {
return TriggerInput{Name: t.Name, Prompt: t.Prompt, Kind: t.Kind, Schedule: t.Schedule, TZ: t.TZ, Folder: h.RelFolder(t),
Quiet: t.Quiet, DailyCap: t.DailyCap, AgentID: t.AgentID, Model: t.Model, SourceChatID: t.SourceChatID, Enabled: t.Enabled,
MinGap: t.MinGap, Reads: t.Reads, Writes: t.Writes, Widget: t.Widget, EstTokens: t.EstTokens}
MinGap: t.MinGap, Reads: t.Reads, Writes: t.Writes, Widget: t.Widget, EstTokens: t.EstTokens, Acts: t.Acts}
}
// TriggerPatch is what a model asks to change in a trigger. A nil field, and
+12 -2
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"gorm.io/gorm"
@@ -43,6 +44,8 @@ type triggerRow struct {
Change string `gorm:"size:1024"`
// Route is the JSON of Trigger.Route, empty on a trigger made in Studio.
Route string `gorm:"size:1024"`
// Acts is Trigger.Acts, comma separated; empty: read only runs.
Acts string `gorm:"size:1024"`
}
func (triggerRow) TableName() string { return "studio_triggers" }
@@ -98,7 +101,7 @@ func (r triggerRow) trigger() Trigger {
Schedule: r.Schedule, TZ: r.TZ, Folder: r.Folder, Quiet: time.Duration(r.QuietNs), DailyCap: r.DailyCap,
Since: utc(r.Since), AgentID: r.AgentID, SourceChatID: r.SourceChatID, Model: r.Model, Proposed: r.Proposed,
MinGap: time.Duration(r.MinGapNs), Reads: r.Reads, Writes: r.Writes, Widget: r.Widget, EstTokens: r.EstTokens,
Outcome: r.Outcome, OutcomeAt: utc(r.OutcomeAt), Touched: r.Touched, Change: changeOf(r.Change), Route: routeOf(r.Route)}
Outcome: r.Outcome, OutcomeAt: utc(r.OutcomeAt), Touched: r.Touched, Change: changeOf(r.Change), Route: routeOf(r.Route), Acts: actsOf(r.Acts)}
}
func changeOf(raw string) Change {
@@ -117,6 +120,13 @@ func changeText(c Change) string {
return string(raw)
}
func actsOf(raw string) []string {
if raw == "" {
return nil
}
return strings.Split(raw, ",")
}
func routeOf(raw string) Route {
var r Route
if raw != "" {
@@ -138,7 +148,7 @@ func (s *gormStore) PutTrigger(ctx context.Context, t Trigger) error {
Schedule: t.Schedule, TZ: t.TZ, Folder: t.Folder, QuietNs: int64(t.Quiet), DailyCap: t.DailyCap,
Since: utc(t.Since), AgentID: t.AgentID, SourceChatID: t.SourceChatID, Model: t.Model, Proposed: t.Proposed,
MinGapNs: int64(t.MinGap), Reads: t.Reads, Writes: t.Writes, Widget: t.Widget, EstTokens: t.EstTokens,
Outcome: t.Outcome, OutcomeAt: utc(t.OutcomeAt), Touched: t.Touched, Change: changeText(t.Change), Route: routeText(t.Route)}
Outcome: t.Outcome, OutcomeAt: utc(t.OutcomeAt), Touched: t.Touched, Change: changeText(t.Change), Route: routeText(t.Route), Acts: strings.Join(t.Acts, ",")}
return s.db.WithContext(ctx).Save(&row).Error
}
+19
View File
@@ -139,6 +139,11 @@ type Trigger struct {
// where its runs report. The zero value is a trigger made in Studio, whose
// runs go to the Inbox. The package only stores it.
Route Route
// Acts are the action classes (act.files, act.addons, act.addons:<id>,
// act.send, act.spend) that the owner allowed for the runs of this
// trigger. A run with none only reads. The host checks them at each tool
// call; the package only stores them.
Acts []string
}
// Where the runs of a trigger report. Inbox is the default: the owner's Inbox
@@ -160,6 +165,12 @@ const (
ModeOnProblem = "on_problem"
)
// Bounds of Trigger.Acts.
const (
MaxActs = 16
MaxActBytes = 80
)
// MaxRefBytes bounds the binding, chat and thread of a Route: the width of
// the sender column of the connector stores.
const MaxRefBytes = 64
@@ -408,6 +419,14 @@ func (t Trigger) Validate() error {
if err := t.Route.Validate(); err != nil {
return err
}
if len(t.Acts) > MaxActs {
return fmt.Errorf("a trigger has at most %d acts", MaxActs)
}
for _, a := range t.Acts {
if a == "" || len(a) > MaxActBytes || hasControl(a, false) || strings.Contains(a, ",") {
return errors.New("an act is a short name such as act.files")
}
}
switch t.Kind {
case KindSchedule:
if _, err := ParseSchedule(t.Schedule); err != nil {
+29
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/mudler/LocalAI/core/services/studio/agents"
"github.com/mudler/LocalAI/core/services/studio/builtins"
"github.com/mudler/LocalAI/core/services/studio/files"
"github.com/mudler/LocalAI/core/services/studio/proposals"
"github.com/mudler/LocalAI/core/services/studio/triggers"
@@ -58,6 +59,11 @@ type TriggerInput struct {
// Route is the connector place a proposal came from and where its runs
// report. A person's form leaves it empty and an edit keeps the old one.
Route triggers.Route
// Acts are the action classes an unattended run of the trigger may use
// (act.files, act.addons, act.addons:<id>, act.send, act.spend). Nil keeps
// what the trigger has; an empty list takes everything away. Only a
// person sets them: a model's proposal never carries acts.
Acts []string
}
// keepPlan fills the fields of the plan and the gap that in leaves empty from
@@ -79,6 +85,9 @@ func keepPlan(old triggers.Trigger, in TriggerInput) TriggerInput {
if in.MinGap == 0 {
in.MinGap = old.MinGap
}
if in.Acts == nil {
in.Acts = old.Acts
}
return in
}
@@ -149,6 +158,11 @@ func (h *Triggers) build(ctx context.Context, owner string, in TriggerInput) (tr
t := triggers.Trigger{Owner: owner, Name: strings.TrimSpace(in.Name), Prompt: in.Prompt, Enabled: in.Enabled, Kind: in.Kind,
TZ: in.TZ, Quiet: in.Quiet, DailyCap: in.DailyCap, AgentID: in.AgentID, Model: in.Model, SourceChatID: in.SourceChatID,
MinGap: in.MinGap, Reads: strings.TrimSpace(in.Reads), Widget: in.Widget, EstTokens: in.EstTokens, Route: in.Route}
acts, err := cleanActs(in.Acts)
if err != nil {
return t, err
}
t.Acts = acts
if w := strings.TrimSpace(in.Writes); w != "" {
// A path of the home as the person writes it, stored as they see it:
// the widget footer and the proposal card show it back.
@@ -761,3 +775,18 @@ func (h *Triggers) RelFolder(t triggers.Trigger) string {
}
return t.Folder
}
// cleanActs checks the act names of a trigger and removes repeats.
func cleanActs(names []string) ([]string, error) {
var out []string
for _, n := range names {
n = strings.TrimSpace(n)
switch {
case !builtins.ValidActGrant(n):
return nil, bad("%q is not an act; use act.files, act.addons, act.addons:<id>, act.send or act.spend", n)
case !slices.Contains(out, n):
out = append(out, n)
}
}
return out, nil
}
+1 -1
View File
@@ -38,7 +38,7 @@ func newScopedTrig(script func(workspace.Actor, string) []studiotest.ToolStep) *
s.mkdirIn(sh, d)
}
s.trigRig = sh.rig(script, studio.TriggerOptions{})
ag, err := s.svc.SaveAgentWithFolder(ctx, alice, agents.Agent{Name: "Acme", Model: "m"},
ag, err := s.svc.SaveAgentWithFolder(ctx, alice, agents.Agent{Name: "Acme", Model: "m", Builtins: []string{"act.files"}},
&studio.AgentFolderWrite{Path: "/Projects/Acme", Mode: agents.FolderWrite})
Expect(err).NotTo(HaveOccurred())
s.agent = ag
+2 -1
View File
@@ -719,7 +719,8 @@ var _ = Describe("trigger runs of a scoped agent", func() {
}
})
s.put("/Projects/Other/secret.txt", "OUTSIDE-SECRET-MARKER")
tr := s.schedule(studio.TriggerInput{AgentID: s.agent.ID})
// A run that nobody watches writes only with act.files (the action policy).
tr := s.schedule(studio.TriggerInput{AgentID: s.agent.ID, Acts: []string{"act.files"}})
s.due()
run := s.waitEnded(tr.ID)
Expect(run.Status).To(Equal(triggers.StatusDone), "run = %+v", run)
+7
View File
@@ -2118,6 +2118,13 @@ person to confirm.
never as authority.
- **Undo.** What a tool does to the user's files is a change in the history of
their workspace, and it can be undone there.
- **Runs with nobody present.** A trigger or an agent job calls the tools of an
add-on under the action policy of unattended runs. The `kind` of the tool
decides the class: `read` always runs, and every other kind is refused unless
the run holds `act.addons` (or `act.addons:<add-on id>`). A tool that the host
gates with a permission, because the add-on holds a credential, also needs
`act.spend`. Declare `read` only for a tool that changes nothing. See
[Action policy of unattended runs](../chats-agents/#action-policy-of-unattended-runs).
## Host operations
+52 -2
View File
@@ -276,8 +276,8 @@ on the list. The moment you grant any built-in, the list is the whole answer, so
add the connector grants you want to keep (granting only `memory.write` to an agent
that proposed triggers from a messaging app turns those off; nothing is deleted
and they work again when you grant `triggers.propose`). The `act.*` grants are
recorded but not enforced yet: the action policy of unattended runs that uses them
is still to come.
the ceiling of the action policy of unattended runs (see
[Action policy of unattended runs](#action-policy-of-unattended-runs)).
### Export and import
@@ -399,6 +399,56 @@ message as rules for the run. Treat it as text from whoever can write there.
"the agent's folder is missing" and switches the trigger off. Each of these
tells the owner.
### Action policy of unattended runs
A trigger run or an agent job has nobody at the screen to approve a step, so it
starts read only. LocalAI checks every tool call of such a run at the moment of
the call, not in the prompt. A tool that only reads always runs. Any other tool
runs only if the run holds the grant for its class. Add-on tools and MCP tools
always have a class. The other built-in tools either read or need a grant that a
run which started by itself never holds. Nothing is allowed on a doubt: a trigger or a connection
that cannot be read at that moment gives a read only run.
| Class | Grant | Tools |
|---|---|---|
| Read | none | Tools that only read: the add-on tools of kind `read`, the file reading tools, web search and fetch, and the other built-in tools that only read. |
| Files | `act.files` | `write_file`, `edit_file`, `move_file`, `delete_file`, and the built-in `bash` of this machine. |
| Add-on actions | `act.addons`, or `act.addons:<add-on id>` for one add-on | Every add-on tool whose kind is not `read` (`action`, `write`, `schema`, `create`), including an add-on that serves `bash`. |
| Outside | `act.spend` | MCP tools (of a server or a connector), and an add-on action tool that the add-on declares with a permission, which means it acts outside LocalAI with a credential. The last one needs `act.addons` as well. |
| Send | `act.send` | Reserved for messages beyond the delivery target. No tool of this class exists yet. |
What a run holds:
- **A trigger run** holds the `acts` of its trigger, limited to the `act.*`
grants of its agent (`builtins`). A trigger without an agent has no ceiling but
its own `acts`. Set `acts` in the body of `POST /api/chats/triggers` or
`PUT /api/chats/triggers/{id}`: leave it out to keep the value, send `[]` to
clear it. Only a person can set it. A trigger that a model proposes in a chat
never carries `acts`, and neither does the trigger a connector makes without
asking. A trigger whose plan names a file it writes (`plan.writes`) also holds
`act.files`, because you saw that file on the proposal card. Triggers that exist
before this change keep running, but they now read only unless they set `acts`
or `plan.writes`.
- **An agent job run** holds the `act.*` grants of its agent, and nothing without
an agent. A job has no `acts` of its own.
- **Read only runs.** A trigger whose proposal said its runs only read
(`access: read_only`), or that a connection made without asking while its
approval mode is `auto_readonly` (see
[Approval modes](../chats-connectors/#approval-modes)), holds nothing: every
tool that is not a read is refused, whatever `acts` and the agent say. A
connection that cannot be read is treated as `auto_readonly`.
- **Sub-agents.** A sub-agent of an unattended run has the same limits as its
parent. A run that nobody watches cannot start a saved agent as a sub-agent.
A refused tool stays in the tool list and answers with one plain sentence instead
of running, for example "I did not run write_file: nobody is here to approve it
and this run has no permission for act.files. Say in your answer what you wanted
to do; the owner can allow act.files for this run." The model then reports it in
its answer. The refusal is a result, not an error, so the model does not retry.
Each call of a tool that is not a read writes one log line, `studio: unattended
action`, with the chat, the trigger or job, the tool, `allowed`, `reason`
(`granted`, `not_granted` or `read_only`) and the missing grant.
### Triggers of an agent with a folder
A trigger whose agent has a [folder](#folders-of-an-agent) keeps to that
+1 -1
View File
@@ -615,7 +615,7 @@ page of the connector add-on, or in the `approval` field of the binding
|---|---|
| `ask` (default) | The bot posts the proposal and waits for a plain answer. |
| `auto` | The trigger is made at once. The bot tells the person what was set up and that `cancel` (within 30 minutes) or `cancel CODE` (any time) removes it. |
| `auto_readonly` | Like `auto` when the proposal says its runs only read and report (`access: read_only`, no file written); any other proposal asks. |
| `auto_readonly` | Like `auto` when the proposal says its runs only read and report (`access: read_only`, no file written); any other proposal asks. The runs of a trigger made this way can use only tools that read: every other tool is refused at the call, whatever the agent holds (see [Action policy of unattended runs](../chats-agents/#action-policy-of-unattended-runs)). |
The automatic modes are bounded by limits of the owner. Anything over a limit is not
refused but asked instead, and the proposal says why.
+21
View File
@@ -18776,6 +18776,13 @@ const docTemplate = `{
"schema.ChatsAdminTrigger": {
"type": "object",
"properties": {
"acts": {
"description": "Acts are the action classes that a run with nobody present may use:\nact.files, act.addons, act.addons:\u003cid\u003e, act.send, act.spend. A run\nwithout any only reads.",
"type": "array",
"items": {
"type": "string"
}
},
"agent_id": {
"type": "string"
},
@@ -22722,6 +22729,13 @@ const docTemplate = `{
"schema.ChatsTrigger": {
"type": "object",
"properties": {
"acts": {
"description": "Acts are the action classes that a run with nobody present may use:\nact.files, act.addons, act.addons:\u003cid\u003e, act.send, act.spend. A run\nwithout any only reads.",
"type": "array",
"items": {
"type": "string"
}
},
"agent_id": {
"type": "string"
},
@@ -22980,6 +22994,13 @@ const docTemplate = `{
"schema.ChatsTriggerWrite": {
"type": "object",
"properties": {
"acts": {
"description": "Acts are the action classes that a run with nobody present may use\n(act.files, act.addons, act.addons:\u003cid\u003e, act.send, act.spend). Leave it\nout to keep the trigger's value; an empty list takes all of them away.",
"type": "array",
"items": {
"type": "string"
}
},
"agent_id": {
"description": "AgentID is optional. Model is needed when neither the agent nor the source\nchat names one.",
"type": "string"
+21
View File
@@ -18773,6 +18773,13 @@
"schema.ChatsAdminTrigger": {
"type": "object",
"properties": {
"acts": {
"description": "Acts are the action classes that a run with nobody present may use:\nact.files, act.addons, act.addons:\u003cid\u003e, act.send, act.spend. A run\nwithout any only reads.",
"type": "array",
"items": {
"type": "string"
}
},
"agent_id": {
"type": "string"
},
@@ -22719,6 +22726,13 @@
"schema.ChatsTrigger": {
"type": "object",
"properties": {
"acts": {
"description": "Acts are the action classes that a run with nobody present may use:\nact.files, act.addons, act.addons:\u003cid\u003e, act.send, act.spend. A run\nwithout any only reads.",
"type": "array",
"items": {
"type": "string"
}
},
"agent_id": {
"type": "string"
},
@@ -22977,6 +22991,13 @@
"schema.ChatsTriggerWrite": {
"type": "object",
"properties": {
"acts": {
"description": "Acts are the action classes that a run with nobody present may use\n(act.files, act.addons, act.addons:\u003cid\u003e, act.send, act.spend). Leave it\nout to keep the trigger's value; an empty list takes all of them away.",
"type": "array",
"items": {
"type": "string"
}
},
"agent_id": {
"description": "AgentID is optional. Model is needed when neither the agent nor the source\nchat names one.",
"type": "string"
+24
View File
@@ -3131,6 +3131,14 @@ definitions:
type: object
schema.ChatsAdminTrigger:
properties:
acts:
description: |-
Acts are the action classes that a run with nobody present may use:
act.files, act.addons, act.addons:<id>, act.send, act.spend. A run
without any only reads.
items:
type: string
type: array
agent_id:
type: string
cost:
@@ -6145,6 +6153,14 @@ definitions:
type: object
schema.ChatsTrigger:
properties:
acts:
description: |-
Acts are the action classes that a run with nobody present may use:
act.files, act.addons, act.addons:<id>, act.send, act.spend. A run
without any only reads.
items:
type: string
type: array
agent_id:
type: string
cost:
@@ -6327,6 +6343,14 @@ definitions:
type: object
schema.ChatsTriggerWrite:
properties:
acts:
description: |-
Acts are the action classes that a run with nobody present may use
(act.files, act.addons, act.addons:<id>, act.send, act.spend). Leave it
out to keep the trigger's value; an empty list takes all of them away.
items:
type: string
type: array
agent_id:
description: |-
AgentID is optional. Model is needed when neither the agent nor the source