discourse/plugins/discourse-ai/lib/agents/tools/assign.rb
Rafael dos Santos Silva fe5e4a27e9
FEATURE: Add human-in-the-loop approval queue for AI agent tool actions (#38446)
## Summary

AI agents have 13 moderation tools (close_topic, delete_topic,
edit_tags, edit_post, etc.) that currently execute immediately without
human oversight. This adds an optional approval queue that routes these
tool actions through Discourse's review queue for moderator approval
before execution.

- **New `require_approval` toggle** on AI agents — when enabled,
moderation tool calls are intercepted and sent to the review queue
instead of executing immediately
- **Review queue integration** — moderators see the agent name, tool
name, parameters, and a rendered snippet of the triggering post, then
approve or reject
- **Loop prevention** — approved tool execution is wrapped in
`DiscourseAutomation.set_active_automation` to prevent automation
re-trigger loops (e.g., `edit_tags` → `topic_tags_changed` → automation
fires again)

### New files
- `AiToolAction` model — stores tool name, parameters (JSONB), agent/bot
user refs, and triggering post ID
- `ReviewableAiToolAction` — Reviewable subclass with approve (executes
tool) and reject (discards) actions
- `ReviewableAiToolActionSerializer` — serializes target tool data and
payload context
- Review queue frontend component — displays tool action details and
post snippet
- Two migrations: `ai_tool_actions` table and `require_approval` column
on `ai_agents`

### Modified files
- `Tool` base class gains `requires_approval?` (default `false`),
overridden to `true` on all 13 moderation tools
- `Bot#invoke_tool` — intercepts tools when both tool and agent opt in
to approval
- Agent admin editor — new "Require approval" checkbox
- Agent REST model — `require_approval` added to attribute whitelists
for save payloads
- Serializer, controller, plugin.rb — wired up for the new field and
reviewable type
2026-03-13 12:46:59 -03:00

113 lines
3.4 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseAi
module Agents
module Tools
class Assign < Tool
def self.signature
{
name: name,
description:
"Assigns or unassigns a topic to a user or group based on the assigned parameter.",
parameters: [
{
name: "topic_id",
description: "The ID of the topic",
type: "integer",
required: true,
},
{
name: "assigned",
description: "true to assign, false to unassign",
type: "boolean",
required: true,
},
{
name: "username",
description: "The username to assign the topic to (required when assigning)",
type: "string",
},
{
name: "group_name",
description: "The group name to assign the topic to, as an alternative to username",
type: "string",
},
{
name: "note",
description: "An optional note to include with the assignment",
type: "string",
},
{
name: "reason",
description: "Short explanation of why the topic is being assigned or unassigned",
type: "string",
required: true,
},
],
}
end
def self.name
"assign"
end
def self.requires_approval?
true
end
def invoke
if !defined?(::Assigner)
return(error_response(I18n.t("discourse_ai.ai_bot.assign.errors.plugin_not_installed")))
end
topic = Topic.find_by(id: parameters[:topic_id])
return error_response(I18n.t("discourse_ai.ai_bot.assign.errors.not_found")) if !topic
if !guardian.can_assign?
return error_response(I18n.t("discourse_ai.ai_bot.assign.errors.not_allowed"))
end
if reason.blank?
return error_response(I18n.t("discourse_ai.ai_bot.assign.errors.no_reason"))
end
assigner = ::Assigner.new(topic, acting_user)
if !parameters[:assigned]
assigner.unassign
return { status: "success", message: I18n.t("discourse_ai.ai_bot.assign.success") }
end
assign_to = find_assign_to
if !assign_to
return error_response(I18n.t("discourse_ai.ai_bot.assign.errors.assignee_not_found"))
end
result = assigner.assign(assign_to, note: parameters[:note])
if result[:success]
{ status: "success", message: I18n.t("discourse_ai.ai_bot.assign.success") }
else
error_response(
result[:error] || I18n.t("discourse_ai.ai_bot.assign.errors.assign_failed"),
)
end
end
def description_args
{ topic_id: parameters[:topic_id], assigned: parameters[:assigned] }
end
private
def find_assign_to
if parameters[:username].present?
User.find_by(username: parameters[:username])
elsif parameters[:group_name].present?
Group.find_by(name: parameters[:group_name])
end
end
end
end
end
end