diff --git a/db/schema.sql b/db/schema.sql index 800fdad..fbc5e5d 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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(); diff --git a/db/triggers.go b/db/triggers.go index a99f683..eb0cd04 100644 --- a/db/triggers.go +++ b/db/triggers.go @@ -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) { diff --git a/server/scheduler.go b/server/scheduler.go index 63d247c..3f83063 100644 --- a/server/scheduler.go +++ b/server/scheduler.go @@ -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 diff --git a/server/triggers.go b/server/triggers.go index 41d01db..43a981c 100644 --- a/server/triggers.go +++ b/server/triggers.go @@ -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 diff --git a/web/src/components/agent-editor.tsx b/web/src/components/agent-editor.tsx index 52ae7f8..4173f07 100644 --- a/web/src/components/agent-editor.tsx +++ b/web/src/components/agent-editor.tsx @@ -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([]); - const [intervalSec, setIntervalSec] = React.useState("0"); + const [tools, setTools] = React.useState([]); + 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([]); 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 }) { {/* 定时 */}
-
- - setIntervalSec(e.target.value)} /> - 秒(0=关) -
- {Number(intervalSec) > 0 && ( -