feat(agent): 触发器新增「工具调用」条件 + 定时触发改为复选框展开

- 定时触发统一为前置复选框,勾选后展开「每 N 秒」+ 消息(与其它条件一致)
- 新增工具调用触发(P3 第 5 种条件):
  - 选中系统工具列表(至少一个),任务执行中这些工具每次调用完成即触发 agent
  - 触发消息带上任务 id/描述/目标 + 工具名 + 入参 + 返回内容(各截断 1500 字)
  - 只读任务执行的 activity,触发跑的会话写 conversation_activities,天然无自触发循环
- DB: agent_triggers 加 on_tool_call/tool_call_message/tool_names 三列;
  已发版,附幂等 ALTER TABLE ADD COLUMN IF NOT EXISTS 供旧库升级补列
- scheduler: 新增 last_toolcall_id 水位线(首启 seed) + fireToolCalls,以 tool_result
  为水位、回连 tool_use 取入参,按选中工具集合匹配后触发

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Autumn-27andClaude Opus 4.8 committed 2026-08-08 02:59:07 -04:00
1 parent 82bd19a1cd
commit f9716269ea
6 files changed
+275 -51

No files matched your search

+7
View File
@@ -425,15 +425,22 @@ CREATE TABLE IF NOT EXISTS agent_triggers (
on_finding BOOLEAN NOT NULL DEFAULT false,
on_goal_met BOOLEAN NOT NULL DEFAULT false,
on_task_timeout BOOLEAN NOT NULL DEFAULT false,
on_tool_call BOOLEAN NOT NULL DEFAULT false,
interval_message TEXT NOT NULL DEFAULT '',
finding_message TEXT NOT NULL DEFAULT '',
goal_message TEXT NOT NULL DEFAULT '',
task_timeout_message TEXT NOT NULL DEFAULT '',
tool_call_message TEXT NOT NULL DEFAULT '',
tool_names TEXT NOT NULL DEFAULT '',
last_fire TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_agent_triggers_agent ON agent_triggers(agent_key);
-- 加列迁移(已发版,旧库升级补列;新库 CREATE 已含这些列,ALTER 为 no-op)。幂等,每次启动可重复执行。
ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS on_tool_call BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS tool_call_message TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS tool_names TEXT NOT NULL DEFAULT '';
DROP TRIGGER IF EXISTS trg_agent_triggers_upd ON agent_triggers;
CREATE TRIGGER trg_agent_triggers_upd BEFORE UPDATE ON agent_triggers
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+75 -19
View File
@@ -6,8 +6,10 @@ import (
"time"
)
// AgentTrigger is one P3 trigger attached to a custom agent. Four trigger
// conditions can be on at once: interval / on_finding / on_goal_met / on_task_timeout.
// AgentTrigger is one P3 trigger attached to a custom agent. Five trigger
// conditions can be on at once: interval / on_finding / on_goal_met /
// on_task_timeout / on_tool_call. ToolNames scopes the tool-call trigger to a
// non-empty set of tool keys (empty is rejected at the API layer for on_tool_call).
type AgentTrigger struct {
ID int64 `json:"id"`
AgentKey string `json:"agent_key"`
@@ -16,22 +18,43 @@ type AgentTrigger struct {
OnFinding bool `json:"on_finding"`
OnGoalMet bool `json:"on_goal_met"`
OnTaskTimeout bool `json:"on_task_timeout"`
OnToolCall bool `json:"on_tool_call"`
IntervalMessage string `json:"interval_message"` // 各触发条件的独立用户消息
FindingMessage string `json:"finding_message"`
GoalMessage string `json:"goal_message"`
TaskTimeoutMessage string `json:"task_timeout_message"`
ToolCallMessage string `json:"tool_call_message"`
ToolNames []string `json:"tool_names"` // 选中的工具 key 列表(DB 存 JSON 文本)
LastFire *time.Time `json:"last_fire,omitempty"`
}
const triggerCols = `id, agent_key, enabled, interval_sec, on_finding, on_goal_met, on_task_timeout, interval_message, finding_message, goal_message, task_timeout_message, last_fire`
const triggerCols = `id, agent_key, enabled, interval_sec, on_finding, on_goal_met, on_task_timeout, on_tool_call, interval_message, finding_message, goal_message, task_timeout_message, tool_call_message, tool_names, last_fire`
// marshalToolNames encodes the tool-key list as JSON text for the tool_names column.
// A nil/empty list stores "" (not "null"/"[]") so the column default stays clean.
func marshalToolNames(names []string) string {
if len(names) == 0 {
return ""
}
b, err := json.Marshal(names)
if err != nil {
return ""
}
return string(b)
}
func scanTrigger(sc interface{ Scan(...any) error }) (*AgentTrigger, error) {
var t AgentTrigger
var lf sql.NullTime
if err := sc.Scan(&t.ID, &t.AgentKey, &t.Enabled, &t.IntervalSec, &t.OnFinding, &t.OnGoalMet, &t.OnTaskTimeout,
&t.IntervalMessage, &t.FindingMessage, &t.GoalMessage, &t.TaskTimeoutMessage, &lf); err != nil {
var toolNames string
if err := sc.Scan(&t.ID, &t.AgentKey, &t.Enabled, &t.IntervalSec, &t.OnFinding, &t.OnGoalMet, &t.OnTaskTimeout, &t.OnToolCall,
&t.IntervalMessage, &t.FindingMessage, &t.GoalMessage, &t.TaskTimeoutMessage, &t.ToolCallMessage, &toolNames, &lf); err != nil {
return nil, err
}
t.ToolNames = []string{}
if toolNames != "" {
_ = json.Unmarshal([]byte(toolNames), &t.ToolNames)
}
if lf.Valid {
t.LastFire = &lf.Time
}
@@ -41,18 +64,18 @@ func scanTrigger(sc interface{ Scan(...any) error }) (*AgentTrigger, error) {
// CreateTrigger inserts a trigger for agentKey and returns it.
func (d *DB) CreateTrigger(t *AgentTrigger) (*AgentTrigger, error) {
row := d.QueryRow(`
INSERT INTO agent_triggers(agent_key, enabled, interval_sec, on_finding, on_goal_met, on_task_timeout, interval_message, finding_message, goal_message, task_timeout_message)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING `+triggerCols,
t.AgentKey, t.Enabled, t.IntervalSec, t.OnFinding, t.OnGoalMet, t.OnTaskTimeout,
t.IntervalMessage, t.FindingMessage, t.GoalMessage, t.TaskTimeoutMessage)
INSERT INTO agent_triggers(agent_key, enabled, interval_sec, on_finding, on_goal_met, on_task_timeout, on_tool_call, interval_message, finding_message, goal_message, task_timeout_message, tool_call_message, tool_names)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING `+triggerCols,
t.AgentKey, t.Enabled, t.IntervalSec, t.OnFinding, t.OnGoalMet, t.OnTaskTimeout, t.OnToolCall,
t.IntervalMessage, t.FindingMessage, t.GoalMessage, t.TaskTimeoutMessage, t.ToolCallMessage, marshalToolNames(t.ToolNames))
return scanTrigger(row)
}
// UpdateTrigger updates a trigger's fields (not last_fire).
func (d *DB) UpdateTrigger(t *AgentTrigger) error {
_, err := d.Exec(`UPDATE agent_triggers SET enabled=$2, interval_sec=$3, on_finding=$4, on_goal_met=$5, on_task_timeout=$6, interval_message=$7, finding_message=$8, goal_message=$9, task_timeout_message=$10 WHERE id=$1`,
t.ID, t.Enabled, t.IntervalSec, t.OnFinding, t.OnGoalMet, t.OnTaskTimeout,
t.IntervalMessage, t.FindingMessage, t.GoalMessage, t.TaskTimeoutMessage)
_, err := d.Exec(`UPDATE agent_triggers SET enabled=$2, interval_sec=$3, on_finding=$4, on_goal_met=$5, on_task_timeout=$6, on_tool_call=$7, interval_message=$8, finding_message=$9, goal_message=$10, task_timeout_message=$11, tool_call_message=$12, tool_names=$13 WHERE id=$1`,
t.ID, t.Enabled, t.IntervalSec, t.OnFinding, t.OnGoalMet, t.OnTaskTimeout, t.OnToolCall,
t.IntervalMessage, t.FindingMessage, t.GoalMessage, t.TaskTimeoutMessage, t.ToolCallMessage, marshalToolNames(t.ToolNames))
return err
}
@@ -123,13 +146,17 @@ ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value`, key, value)
// TaskEvent is a finding/goal event carrying the owning task's info, used to
// compose the trigger message context.
type TaskEvent struct {
NodeID int64 `json:"node_id"`
TaskID int64 `json:"task_id"`
TaskDesc string `json:"task_description"`
TaskGoal string `json:"task_goal"`
Summary string `json:"summary"` // finding summary / goal text
VulnClass string `json:"vulnclass"` // finding only
Severity string `json:"severity"` // finding only
NodeID int64 `json:"node_id"`
TaskID int64 `json:"task_id"`
TaskDesc string `json:"task_description"`
TaskGoal string `json:"task_goal"`
Summary string `json:"summary"` // finding summary / goal text
VulnClass string `json:"vulnclass"` // finding only
Severity string `json:"severity"` // finding only
Tool string `json:"tool"` // tool-call only: tool name
ToolInput string `json:"tool_input"` // tool-call only: 入参(JSON 文本)
ToolOutput string `json:"tool_output"` // tool-call only: 返回内容
ToolIsErr bool `json:"tool_is_err"` // tool-call only: 工具返回是否为错误
}
// NewFindingsSince returns findings with node id > lastID across all live tasks,
@@ -183,6 +210,35 @@ ORDER BY id`, lastID)
return out, rows.Err()
}
// NewToolCallsSince returns completed tool calls (a tool_result row) with activity
// id > lastID across all live tasks, ordered by id (monotonic watermark → no
// double-fire). It is driven by tool_result rows (the tool finished, so both input
// and output are available) and joins back to the paired tool_use row for the input.
// Only task-execution activity is scanned — triggered agent runs are conversations
// (conversation_activities), so a tool-call trigger never fires on its own output.
func (d *DB) NewToolCallsSince(lastID int64) ([]TaskEvent, error) {
rows, err := d.Query(`
SELECT r.id, t.id, t.description, t.goal, r.tool, COALESCE(u.detail,''), COALESCE(r.detail,''), r.is_error
FROM activity r
JOIN tasks t ON t.exploration_id = r.exploration_id
LEFT JOIN activity u ON u.exploration_id = r.exploration_id AND u.tool_use_id = r.tool_use_id AND u.kind='tool_use'
WHERE r.kind='tool_result' AND r.id > $1 AND r.tool <> '' AND t.deleted_at IS NULL
ORDER BY r.id`, lastID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []TaskEvent{}
for rows.Next() {
var e TaskEvent
if err := rows.Scan(&e.NodeID, &e.TaskID, &e.TaskDesc, &e.TaskGoal, &e.Tool, &e.ToolInput, &e.ToolOutput, &e.ToolIsErr); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// MetGoals returns all met goals across live tasks (the scheduler filters out the
// ones it already fired for via the persisted fired-set).
func (d *DB) MetGoals() ([]TaskEvent, error) {
+75 -3
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log"
"strconv"
"strings"
"time"
"github.com/Autumn-27/artex/db"
@@ -25,9 +26,10 @@ type Scheduler struct {
}
const (
schedKeyLastFinding = "last_finding_id" // watermark: max finding node id fired for
schedKeyFiredGoals = "fired_goals" // JSON array of goal node ids already fired
schedKeyLastTimeout = "last_timeout_id" // watermark: max task id fired for task timeout
schedKeyLastFinding = "last_finding_id" // watermark: max finding node id fired for
schedKeyFiredGoals = "fired_goals" // JSON array of goal node ids already fired
schedKeyLastTimeout = "last_timeout_id" // watermark: max task id fired for task timeout
schedKeyLastToolCall = "last_toolcall_id" // watermark: max activity id fired for tool call
)
func newScheduler(s *Server) *Scheduler {
@@ -86,6 +88,17 @@ func (sc *Scheduler) init() {
}
_ = sc.pg.SetSchedState(schedKeyLastTimeout, strconv.FormatInt(maxID, 10))
}
if sc.mustState(schedKeyLastToolCall) == "" {
var maxID int64
if evs, err := sc.pg.NewToolCallsSince(0); err == nil {
for _, e := range evs {
if e.NodeID > maxID {
maxID = e.NodeID
}
}
}
_ = sc.pg.SetSchedState(schedKeyLastToolCall, strconv.FormatInt(maxID, 10))
}
}
func (sc *Scheduler) step() {
@@ -100,6 +113,7 @@ func (sc *Scheduler) step() {
sc.fireFindings(triggers)
sc.fireGoals(triggers)
sc.fireTaskTimeouts(triggers)
sc.fireToolCalls(triggers)
}
// fireIntervals fires triggers whose interval has elapsed since last_fire.
@@ -215,6 +229,64 @@ func (sc *Scheduler) fireTaskTimeouts(triggers []*db.AgentTrigger) {
_ = sc.pg.SetSchedState(schedKeyLastTimeout, strconv.FormatInt(maxID, 10))
}
// fireToolCalls fires on_tool_call triggers for tool calls (tool_result rows) above
// the persisted watermark whose tool name is in the trigger's selected set. The fire
// message carries the task id/desc/goal + tool name + (truncated) input & output.
func (sc *Scheduler) fireToolCalls(triggers []*db.AgentTrigger) {
var want []*db.AgentTrigger
for _, tr := range triggers {
if tr.OnToolCall && len(tr.ToolNames) > 0 {
want = append(want, tr)
}
}
if len(want) == 0 {
return
}
last, _ := strconv.ParseInt(sc.mustState(schedKeyLastToolCall), 10, 64)
events, err := sc.pg.NewToolCallsSince(last)
if err != nil || len(events) == 0 {
return
}
maxID := last
for _, e := range events {
if e.NodeID > maxID {
maxID = e.NodeID
}
errTag := ""
if e.ToolIsErr {
errTag = "[error] "
}
msgCtx := fmt.Sprintf("\n\n【本次由工具调用触发】\n任务: #%d %s(目标:%s)\n工具: %s\n入参: %s\n返回: %s%s",
e.TaskID, e.TaskDesc, e.TaskGoal, e.Tool, trunc(e.ToolInput, 1500), errTag, trunc(e.ToolOutput, 1500))
for _, tr := range want {
if !containsFold(tr.ToolNames, e.Tool) {
continue
}
sc.s.StartTriggeredRun(tr.AgentKey, fmt.Sprintf("工具触发 · %s · task#%d", e.Tool, e.TaskID), tr.ToolCallMessage+msgCtx, e.TaskID, true)
}
}
_ = sc.pg.SetSchedState(schedKeyLastToolCall, strconv.FormatInt(maxID, 10))
}
// containsFold reports whether name is in set (case-insensitive).
func containsFold(set []string, name string) bool {
for _, s := range set {
if strings.EqualFold(s, name) {
return true
}
}
return false
}
// trunc caps s to max runes, appending an ellipsis + original length when cut.
func trunc(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + fmt.Sprintf("…(已截断,共 %d 字)", len(r))
}
func (sc *Scheduler) mustState(key string) string {
v, _ := sc.pg.GetSchedState(key)
return v
+32 -15
View File
@@ -22,15 +22,30 @@ func (s *Server) pgListTriggers(w http.ResponseWriter, r *http.Request) {
}
type triggerReq struct {
Enabled bool `json:"enabled"`
IntervalSec int `json:"interval_sec"`
OnFinding bool `json:"on_finding"`
OnGoalMet bool `json:"on_goal_met"`
OnTaskTimeout bool `json:"on_task_timeout"`
IntervalMessage string `json:"interval_message"`
FindingMessage string `json:"finding_message"`
GoalMessage string `json:"goal_message"`
TaskTimeoutMessage string `json:"task_timeout_message"`
Enabled bool `json:"enabled"`
IntervalSec int `json:"interval_sec"`
OnFinding bool `json:"on_finding"`
OnGoalMet bool `json:"on_goal_met"`
OnTaskTimeout bool `json:"on_task_timeout"`
OnToolCall bool `json:"on_tool_call"`
IntervalMessage string `json:"interval_message"`
FindingMessage string `json:"finding_message"`
GoalMessage string `json:"goal_message"`
TaskTimeoutMessage string `json:"task_timeout_message"`
ToolCallMessage string `json:"tool_call_message"`
ToolNames []string `json:"tool_names"`
}
// validateTrigger enforces the shared trigger rules for create/update:
// at least one condition, and on_tool_call requires a non-empty tool set.
func validateTrigger(req *triggerReq) string {
if req.IntervalSec == 0 && !req.OnFinding && !req.OnGoalMet && !req.OnTaskTimeout && !req.OnToolCall {
return "至少选择一种触发条件(定时/发现finding/目标达成/任务超时/工具调用)"
}
if req.OnToolCall && len(req.ToolNames) == 0 {
return "工具调用触发至少选择一个工具"
}
return ""
}
func (s *Server) pgCreateTrigger(w http.ResponseWriter, r *http.Request) {
@@ -50,15 +65,16 @@ func (s *Server) pgCreateTrigger(w http.ResponseWriter, r *http.Request) {
if req.IntervalSec < 0 {
req.IntervalSec = 0
}
if req.IntervalSec == 0 && !req.OnFinding && !req.OnGoalMet && !req.OnTaskTimeout {
writeErr(w, 400, "至少选择一种触发条件(定时/发现finding/目标达成/任务超时)")
if msg := validateTrigger(&req); msg != "" {
writeErr(w, 400, msg)
return
}
tr, err := pg.CreateTrigger(&db.AgentTrigger{
AgentKey: a.Key, Enabled: req.Enabled, IntervalSec: req.IntervalSec,
OnFinding: req.OnFinding, OnGoalMet: req.OnGoalMet, OnTaskTimeout: req.OnTaskTimeout,
OnFinding: req.OnFinding, OnGoalMet: req.OnGoalMet, OnTaskTimeout: req.OnTaskTimeout, OnToolCall: req.OnToolCall,
IntervalMessage: req.IntervalMessage, FindingMessage: req.FindingMessage,
GoalMessage: req.GoalMessage, TaskTimeoutMessage: req.TaskTimeoutMessage,
ToolCallMessage: req.ToolCallMessage, ToolNames: req.ToolNames,
})
if err != nil {
writeErr(w, 500, err.Error())
@@ -85,15 +101,16 @@ func (s *Server) pgUpdateTrigger(w http.ResponseWriter, r *http.Request) {
if req.IntervalSec < 0 {
req.IntervalSec = 0
}
if req.IntervalSec == 0 && !req.OnFinding && !req.OnGoalMet && !req.OnTaskTimeout {
writeErr(w, 400, "至少选择一种触发条件(定时/发现finding/目标达成/任务超时)")
if msg := validateTrigger(&req); msg != "" {
writeErr(w, 400, msg)
return
}
if err := pg.UpdateTrigger(&db.AgentTrigger{
ID: id, Enabled: req.Enabled, IntervalSec: req.IntervalSec,
OnFinding: req.OnFinding, OnGoalMet: req.OnGoalMet, OnTaskTimeout: req.OnTaskTimeout,
OnFinding: req.OnFinding, OnGoalMet: req.OnGoalMet, OnTaskTimeout: req.OnTaskTimeout, OnToolCall: req.OnToolCall,
IntervalMessage: req.IntervalMessage, FindingMessage: req.FindingMessage,
GoalMessage: req.GoalMessage, TaskTimeoutMessage: req.TaskTimeoutMessage,
ToolCallMessage: req.ToolCallMessage, ToolNames: req.ToolNames,
}); err != nil {
writeErr(w, 500, err.Error())
return
+83 -14
View File
@@ -688,18 +688,23 @@ function DiffView({ oldText, newText }: { oldText: string; newText: string }) {
}
// AgentTriggersTab manages a custom agent's P3 triggers: list + add + delete.
// Each trigger fires (定时/发现finding/目标达成,可多选) → a new conversation runs
// Each trigger fires (定时/发现finding/目标达成/任务超时/工具调用,可多选) → a new conversation runs
// in parallel with the base user message + auto context appended by the backend.
function AgentTriggersTab({ agentKey }: { agentKey: string }) {
const [triggers, setTriggers] = React.useState<AgentTrigger[]>([]);
const [intervalSec, setIntervalSec] = React.useState("0");
const [tools, setTools] = React.useState<Tool[]>([]);
const [onInterval, setOnInterval] = React.useState(false);
const [intervalSec, setIntervalSec] = React.useState("60");
const [onFinding, setOnFinding] = React.useState(false);
const [onGoalMet, setOnGoalMet] = React.useState(false);
const [onTaskTimeout, setOnTaskTimeout] = React.useState(false);
const [onToolCall, setOnToolCall] = React.useState(false);
const [intervalMsg, setIntervalMsg] = React.useState("");
const [findingMsg, setFindingMsg] = React.useState("");
const [goalMsg, setGoalMsg] = React.useState("");
const [taskTimeoutMsg, setTaskTimeoutMsg] = React.useState("");
const [toolCallMsg, setToolCallMsg] = React.useState("");
const [toolNames, setToolNames] = React.useState<string[]>([]);
const [saving, setSaving] = React.useState(false);
const reload = React.useCallback(() => {
@@ -708,13 +713,24 @@ function AgentTriggersTab({ agentKey }: { agentKey: string }) {
React.useEffect(() => {
reload();
}, [reload]);
React.useEffect(() => {
api.tools().then(setTools).catch(() => setTools([]));
}, []);
function toggleTool(key: string) {
setToolNames((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
}
async function create() {
const n = Math.max(0, Math.floor(Number(intervalSec) || 0));
if (n === 0 && !onFinding && !onGoalMet && !onTaskTimeout) {
const n = onInterval ? Math.max(1, Math.floor(Number(intervalSec) || 0)) : 0;
if (n === 0 && !onFinding && !onGoalMet && !onTaskTimeout && !onToolCall) {
toast.error("至少选择一种触发条件");
return;
}
if (onToolCall && toolNames.length === 0) {
toast.error("工具调用触发至少选择一个工具");
return;
}
setSaving(true);
try {
await api.createTrigger(agentKey, {
@@ -723,20 +739,27 @@ function AgentTriggersTab({ agentKey }: { agentKey: string }) {
on_finding: onFinding,
on_goal_met: onGoalMet,
on_task_timeout: onTaskTimeout,
on_tool_call: onToolCall,
interval_message: intervalMsg.trim(),
finding_message: findingMsg.trim(),
goal_message: goalMsg.trim(),
task_timeout_message: taskTimeoutMsg.trim(),
tool_call_message: toolCallMsg.trim(),
tool_names: onToolCall ? toolNames : [],
});
toast.success("已添加触发器");
setIntervalSec("0");
setOnInterval(false);
setIntervalSec("60");
setOnFinding(false);
setOnGoalMet(false);
setOnTaskTimeout(false);
setOnToolCall(false);
setIntervalMsg("");
setFindingMsg("");
setGoalMsg("");
setTaskTimeoutMsg("");
setToolCallMsg("");
setToolNames([]);
reload();
} catch (e) {
toast.error("添加失败:" + (e as Error).message);
@@ -752,10 +775,13 @@ function AgentTriggersTab({ agentKey }: { agentKey: string }) {
on_finding: t.on_finding,
on_goal_met: t.on_goal_met,
on_task_timeout: t.on_task_timeout,
on_tool_call: t.on_tool_call,
interval_message: t.interval_message,
finding_message: t.finding_message,
goal_message: t.goal_message,
task_timeout_message: t.task_timeout_message,
tool_call_message: t.tool_call_message,
tool_names: t.tool_names,
});
reload();
} catch (e) {
@@ -777,6 +803,7 @@ function AgentTriggersTab({ agentKey }: { agentKey: string }) {
if (t.on_finding) parts.push("发现 finding");
if (t.on_goal_met) parts.push("目标达成");
if (t.on_task_timeout) parts.push("任务超时");
if (t.on_tool_call) parts.push(`工具调用(${t.tool_names.length})`);
return parts.join(" · ") || "(无条件)";
}
@@ -793,15 +820,20 @@ function AgentTriggersTab({ agentKey }: { agentKey: string }) {
{/* 定时 */}
<div className="grid gap-1.5">
<div className="flex items-center gap-2">
<Label htmlFor="tr-interval" className="text-xs">定时触发:每</Label>
<Input id="tr-interval" type="number" min={0} className="h-8 w-24"
value={intervalSec} onChange={(e) => setIntervalSec(e.target.value)} />
<span className="text-muted-foreground text-xs">秒(0=关)</span>
</div>
{Number(intervalSec) > 0 && (
<Textarea className="text-xs" rows={2} value={intervalMsg}
placeholder="定时触发时发给 agent 的话,如:巡检所有任务" onChange={(e) => setIntervalMsg(e.target.value)} />
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onInterval} onCheckedChange={(v) => setOnInterval(!!v)} /> 定时触发
</label>
{onInterval && (
<div className="grid gap-1.5">
<div className="flex items-center gap-2">
<Label htmlFor="tr-interval" className="text-xs">每</Label>
<Input id="tr-interval" type="number" min={1} className="h-8 w-24"
value={intervalSec} onChange={(e) => setIntervalSec(e.target.value)} />
<span className="text-muted-foreground text-xs">秒</span>
</div>
<Textarea className="text-xs" rows={2} value={intervalMsg}
placeholder="定时触发时发给 agent 的话,如:巡检所有任务" onChange={(e) => setIntervalMsg(e.target.value)} />
</div>
)}
</div>
@@ -838,6 +870,37 @@ function AgentTriggersTab({ agentKey }: { agentKey: string }) {
)}
</div>
{/* 工具调用 */}
<div className="grid gap-1.5">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onToolCall} onCheckedChange={(v) => setOnToolCall(!!v)} /> 工具调用时触发
</label>
{onToolCall && (
<div className="grid gap-1.5">
<div className="text-muted-foreground text-xs">
选择要监听的工具(至少一个);任务执行中这些工具每次<b>调用完成</b>都会触发。已选 {toolNames.length} 个。
</div>
<div className="max-h-40 overflow-y-auto rounded-md border p-2">
{tools.length === 0 && <span className="text-muted-foreground text-xs">(工具列表为空)</span>}
<div className="grid gap-1">
{tools.map((tool) => (
<label key={tool.key} className="flex items-start gap-2 text-xs">
<Checkbox className="mt-0.5" checked={toolNames.includes(tool.key)}
onCheckedChange={() => toggleTool(tool.key)} />
<span className="min-w-0">
<span className="font-medium">{tool.key}</span>
{tool.description && <span className="text-muted-foreground line-clamp-1"> {tool.description}</span>}
</span>
</label>
))}
</div>
</div>
<Textarea className="text-xs" rows={2} value={toolCallMsg}
placeholder="工具被调用时发给 agent 的话(系统会附带任务信息、工具入参与返回内容)" onChange={(e) => setToolCallMsg(e.target.value)} />
</div>
)}
</div>
<div>
<Button size="sm" onClick={create} disabled={saving}>
<SaveIcon /> 添加触发器
@@ -864,6 +927,12 @@ function AgentTriggersTab({ agentKey }: { agentKey: string }) {
{t.on_finding && t.finding_message && <div className="line-clamp-1">finding:{t.finding_message}</div>}
{t.on_goal_met && t.goal_message && <div className="line-clamp-1">目标:{t.goal_message}</div>}
{t.on_task_timeout && t.task_timeout_message && <div className="line-clamp-1">超时:{t.task_timeout_message}</div>}
{t.on_tool_call && (
<>
<div className="line-clamp-1">工具:{t.tool_names.join("、") || "(未选)"}</div>
{t.tool_call_message && <div className="line-clamp-1">消息:{t.tool_call_message}</div>}
</>
)}
</div>
</div>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground hover:text-destructive"
+3
View File
@@ -227,10 +227,13 @@ export interface AgentTrigger {
on_finding: boolean; // 任意任务发现 finding 时触发
on_goal_met: boolean; // 任意任务达成目标时触发
on_task_timeout: boolean; // 任意任务超时时触发
on_tool_call: boolean; // 选中工具被调用(执行完成)时触发
interval_message: string; // 各触发条件的独立用户消息
finding_message: string;
goal_message: string;
task_timeout_message: string;
tool_call_message: string;
tool_names: string[]; // on_tool_call 选中的工具 key(至少一个)
last_fire?: string;
}