Add per-agent tool policy support with NO_REPLY placeholder cleanup across channels

Wire AgentToolPolicy from agent spec through loop creation and resolver, passing it to PolicyEngine.FilterTools for per-agent tool restrictions (nil = no restrictions). Set RestrictToWorkspace=true for default agent in managed mode seeding. Clean up thinking/placeholder messages in Discord and Telegram when agent suppresses empty/NO_REPLY responses by publishing empty outbound and deleting placeholders without sending messages.
This commit is contained in:
Viet Tran committed 2026-02-22 22:49:22 +07:00
1 parent 2991283901
commit d56813d726
8 files changed
+58 -22

No files matched your search

+4 -1
View File
@@ -68,8 +68,10 @@ func createAgentLoop(agentID string, cfg *config.Config, router *agent.Router, p
// Per-agent skill allowlist.
// AgentSpec.Skills: nil = all skills, [] = none, ["x","y"] = only those.
var skillAllowList []string
var agentToolPolicy *config.ToolPolicySpec
if spec, ok := cfg.Agents.List[agentID]; ok {
skillAllowList = spec.Skills
agentToolPolicy = spec.Tools
}
loop := agent.NewLoop(agent.LoopConfig{
@@ -82,7 +84,8 @@ func createAgentLoop(agentID string, cfg *config.Config, router *agent.Router, p
Bus: msgBus,
Sessions: sess,
Tools: toolsReg,
ToolPolicy: toolPE,
ToolPolicy: toolPE,
AgentToolPolicy: agentToolPolicy,
OwnerIDs: cfg.Gateway.OwnerIDs,
SkillsLoader: skillsLoader,
SkillAllowList: skillAllowList,
+7
View File
@@ -174,12 +174,19 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
}
// Suppress empty/NO_REPLY responses (matching TS normalize-reply.ts).
// Still publish an empty outbound so channels can clean up placeholder/thinking indicators.
if outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content) {
slog.Info("inbound: suppressed silent/empty reply",
"channel", channel,
"chat_id", chatID,
"session", session,
)
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: "",
Metadata: meta,
})
return
}
+11 -10
View File
@@ -112,16 +112,17 @@ func seedManagedData(dsn string, cfg *config.Config) error {
// 4. Seed default agent (skip if already exists)
workspace := config.ExpandHome(cfg.Agents.Defaults.Workspace)
agent := &store.AgentData{
AgentKey: "default",
DisplayName: "Default Agent",
OwnerID: "system",
AgentType: "open",
Provider: defaultProvider,
Model: modelID,
Workspace: workspace,
IsDefault: true,
Status: "active",
SubagentsConfig: json.RawMessage(`{"maxSpawnDepth":1,"maxConcurrent":20}`),
AgentKey: "default",
DisplayName: "Default Agent",
OwnerID: "system",
AgentType: "open",
Provider: defaultProvider,
Model: modelID,
Workspace: workspace,
RestrictToWorkspace: true,
IsDefault: true,
Status: "active",
SubagentsConfig: json.RawMessage(`{"maxSpawnDepth":1,"maxConcurrent":20}`),
}
if err := stores.Agents.Create(ctx, agent); err != nil {
+13 -10
View File
@@ -43,9 +43,10 @@ type Loop struct {
eventPub bus.EventPublisher // currently unused by Loop; kept for future use
sessions store.SessionStore
tools *tools.Registry
toolPolicy *tools.PolicyEngine // optional: filters tools sent to LLM
running atomic.Bool
tools *tools.Registry
toolPolicy *tools.PolicyEngine // optional: filters tools sent to LLM
agentToolPolicy *config.ToolPolicySpec // per-agent tool policy from DB (nil = no restrictions)
running atomic.Bool
mu sync.Mutex // protects concurrent runs
// Bootstrap/persona context (loaded at startup, injected into system prompt)
@@ -101,9 +102,10 @@ type LoopConfig struct {
Workspace string
Bus bus.EventPublisher
Sessions store.SessionStore
Tools *tools.Registry
ToolPolicy *tools.PolicyEngine // optional: filters tools sent to LLM
OnEvent func(AgentEvent)
Tools *tools.Registry
ToolPolicy *tools.PolicyEngine // optional: filters tools sent to LLM
AgentToolPolicy *config.ToolPolicySpec // per-agent tool policy from DB (nil = no restrictions)
OnEvent func(AgentEvent)
// Bootstrap/persona context
OwnerIDs []string
@@ -174,9 +176,10 @@ func NewLoop(cfg LoopConfig) *Loop {
workspace: cfg.Workspace,
eventPub: cfg.Bus,
sessions: cfg.Sessions,
tools: cfg.Tools,
toolPolicy: cfg.ToolPolicy,
onEvent: cfg.OnEvent,
tools: cfg.Tools,
toolPolicy: cfg.ToolPolicy,
agentToolPolicy: cfg.AgentToolPolicy,
onEvent: cfg.OnEvent,
ownerIDs: cfg.OwnerIDs,
skillsLoader: cfg.SkillsLoader,
skillAllowList: cfg.SkillAllowList,
@@ -403,7 +406,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
// Build provider request with policy-filtered tools
var toolDefs []providers.ToolDefinition
if l.toolPolicy != nil {
toolDefs = l.toolPolicy.FilterTools(l.tools, l.id, l.provider.Name(), nil, nil, false, false)
toolDefs = l.toolPolicy.FilterTools(l.tools, l.id, l.provider.Name(), l.agentToolPolicy, nil, false, false)
} else {
toolDefs = l.tools.ProviderDefs()
}
+1
View File
@@ -136,6 +136,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
Sessions: deps.Sessions,
Tools: toolsReg,
ToolPolicy: deps.ToolPolicy,
AgentToolPolicy: ag.ParseToolsConfig(),
SkillsLoader: deps.Skills,
HasMemory: deps.HasMemory,
ContextFiles: contextFiles,
+11
View File
@@ -87,6 +87,17 @@ func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error {
content := msg.Content
// NO_REPLY cleanup: content is empty when agent suppresses reply.
// Delete placeholder and return without sending any message.
if content == "" {
if pID, ok := c.placeholders.Load(channelID); ok {
c.placeholders.Delete(channelID)
msgID := pID.(string)
_ = c.session.ChannelMessageDelete(channelID, msgID)
}
return nil
}
// Try to edit the placeholder "Thinking..." message
if pID, ok := c.placeholders.Load(channelID); ok {
c.placeholders.Delete(channelID)
+10
View File
@@ -57,6 +57,16 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error {
c.stopThinking.Delete(localKey)
}
// NO_REPLY cleanup: content is empty when agent suppresses reply (prompt injection, etc.).
// Clean up placeholder, then return without sending any message.
if msg.Content == "" && len(msg.Media) == 0 {
if pID, ok := c.placeholders.Load(localKey); ok {
c.placeholders.Delete(localKey)
_ = c.deleteMessage(ctx, chatID, pID.(int))
}
return nil
}
// Handle media attachments if present
if len(msg.Media) > 0 {
// Delete placeholder since we're sending media
+1 -1
View File
@@ -62,7 +62,7 @@ var safeBins = map[string]bool{
"cat": true, "echo": true, "ls": true, "pwd": true, "head": true,
"tail": true, "wc": true, "sort": true, "uniq": true, "grep": true,
"find": true, "which": true, "whoami": true, "date": true,
"uname": true, "id": true, "hostname": true,
"uname": true, "hostname": true,
"df": true, "du": true, "free": true, "uptime": true, "file": true,
"stat": true, "dirname": true, "basename": true, "realpath": true,
// Text processing