feat(agent): 触发后处理可配置(运行模式/合并模式/并发上限) + 修复抽屉内下拉误关
每个自定义 agent 可配置触发后如何调度运行: - 运行模式 serial|parallel:串行排队一次一个 / 每次触发各自并发一个会话 - 合并模式 by_task|all|none(仅 serial):同任务合并 / 全部合并成一条 / 不合并 - parallel 带每 agent 最大并发上限(0=不限),超限排队等空位 实现: - DB: agents 加 trigger_run_mode/trigger_merge_mode/trigger_max_parallel 三列 (schema + 幂等 ALTER 迁移,已发版补旧库);Agent 结构/scan/SetAgentTriggerBehavior - 调度重构: triggerRun bool → triggerActive 计数 + triggerCfg 缓存; StartTriggeredRun 走 pumpLocked/runAndPump 统一泵模型,nextTriggerRun 按合并模式 取(新增 mergeAllRuns);并发安全(session 按 conv-id 隔离,已确认) - config 接口暴露/接收三字段;max_turns 改可选指针,避免触发 tab 部分 patch 清零 - 前端: 触发 tab 顶部加运行/合并模式下拉 + 最大并发输入,改动即存 修复: 抽屉(Sheet)内 Select 下拉 portal 到抽屉外,点选项/点别处被当成点击外部 误关抽屉。SelectContent 改 position=popper,并给 SheetContent 加 onInteractOutside 守卫(按 e.detail.originalEvent.target 判定弹层内点击则不关)。 Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
f9716269ea
commit
6d8fa5bc48
10 files changed
+374
-98
No files matched your search
+31
-2
@@ -132,13 +132,20 @@ type Agent struct {
|
||||
// 任务级超时收尾词(与 per-run 两套;仅 worker/planner 用);空/0=用代码内置默认。
|
||||
TaskTimeoutWrapupPrompt string `json:"task_timeout_wrapup_prompt"`
|
||||
TaskTimeoutWrapupMaxTurns int `json:"task_timeout_wrapup_max_turns"`
|
||||
// P3 触发后处理策略(仅自定义 agent 有意义):
|
||||
// TriggerRunMode serial|parallel — 串行排队 / 每次触发各自并发一个会话
|
||||
// TriggerMergeMode by_task|all|none — 仅 serial 用:同任务合并 / 全部合并 / 不合并
|
||||
// TriggerMaxParallel — 仅 parallel 用的每 agent 并发上限;0=不限
|
||||
TriggerRunMode string `json:"trigger_run_mode"`
|
||||
TriggerMergeMode string `json:"trigger_merge_mode"`
|
||||
TriggerMaxParallel int `json:"trigger_max_parallel"`
|
||||
}
|
||||
|
||||
const agentCols = `id,key,name,COALESCE(description,''),role,builtin,enabled,COALESCE(max_turns,0),COALESCE(run_seconds,600),COALESCE(web_search,false),COALESCE(interactive_shell,false),COALESCE(wrapup_prompt,''),COALESCE(wrapup_max_turns,0),COALESCE(task_timeout_wrapup_prompt,''),COALESCE(task_timeout_wrapup_max_turns,0)`
|
||||
const agentCols = `id,key,name,COALESCE(description,''),role,builtin,enabled,COALESCE(max_turns,0),COALESCE(run_seconds,600),COALESCE(web_search,false),COALESCE(interactive_shell,false),COALESCE(wrapup_prompt,''),COALESCE(wrapup_max_turns,0),COALESCE(task_timeout_wrapup_prompt,''),COALESCE(task_timeout_wrapup_max_turns,0),COALESCE(trigger_run_mode,'serial'),COALESCE(trigger_merge_mode,'by_task'),COALESCE(trigger_max_parallel,5)`
|
||||
|
||||
func scanAgent(sc interface{ Scan(...any) error }) (*Agent, error) {
|
||||
var a Agent
|
||||
err := sc.Scan(&a.ID, &a.Key, &a.Name, &a.Description, &a.Role, &a.Builtin, &a.Enabled, &a.MaxTurns, &a.RunSecs, &a.WebSearch, &a.InteractiveShell, &a.WrapupPrompt, &a.WrapupMaxTurns, &a.TaskTimeoutWrapupPrompt, &a.TaskTimeoutWrapupMaxTurns)
|
||||
err := sc.Scan(&a.ID, &a.Key, &a.Name, &a.Description, &a.Role, &a.Builtin, &a.Enabled, &a.MaxTurns, &a.RunSecs, &a.WebSearch, &a.InteractiveShell, &a.WrapupPrompt, &a.WrapupMaxTurns, &a.TaskTimeoutWrapupPrompt, &a.TaskTimeoutWrapupMaxTurns, &a.TriggerRunMode, &a.TriggerMergeMode, &a.TriggerMaxParallel)
|
||||
return &a, err
|
||||
}
|
||||
|
||||
@@ -300,6 +307,28 @@ func (d *DB) SetAgentRunSeconds(key string, runSecs int) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetAgentTriggerBehavior stores an agent's P3 trigger post-processing策略:
|
||||
// runMode(serial|parallel) / mergeMode(by_task|all|none) / maxParallel(parallel 用,0=不限)。
|
||||
// 枚举做白名单校验,非法值回落默认,避免脏数据把调度 pump 带偏。
|
||||
func (d *DB) SetAgentTriggerBehavior(key, runMode, mergeMode string, maxParallel int) error {
|
||||
switch runMode {
|
||||
case "serial", "parallel":
|
||||
default:
|
||||
runMode = "serial"
|
||||
}
|
||||
switch mergeMode {
|
||||
case "by_task", "all", "none":
|
||||
default:
|
||||
mergeMode = "by_task"
|
||||
}
|
||||
if maxParallel < 0 {
|
||||
maxParallel = 0
|
||||
}
|
||||
_, err := d.Exec(`UPDATE agents SET trigger_run_mode=$1, trigger_merge_mode=$2, trigger_max_parallel=$3 WHERE key=$4`,
|
||||
runMode, mergeMode, maxParallel, key)
|
||||
return err
|
||||
}
|
||||
|
||||
type PromptVar struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
|
||||
@@ -269,10 +269,17 @@ CREATE TABLE IF NOT EXISTS agents (
|
||||
wrapup_max_turns INTEGER NOT NULL DEFAULT 0,
|
||||
task_timeout_wrapup_prompt TEXT NOT NULL DEFAULT '',
|
||||
task_timeout_wrapup_max_turns INTEGER NOT NULL DEFAULT 0,
|
||||
trigger_run_mode TEXT NOT NULL DEFAULT 'serial' CHECK (trigger_run_mode IN ('serial','parallel')),
|
||||
trigger_merge_mode TEXT NOT NULL DEFAULT 'by_task' CHECK (trigger_merge_mode IN ('by_task','all','none')),
|
||||
trigger_max_parallel INTEGER NOT NULL DEFAULT 5,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT agents_role_ck CHECK (role IN ('goals','main','planner','worker','assistant'))
|
||||
);
|
||||
-- 加列迁移(已发版,旧库升级补列;新库 CREATE 已含。迁移不带 CHECK:旧库存量安全 + 后端写入白名单兜底)。
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_run_mode TEXT NOT NULL DEFAULT 'serial';
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_merge_mode TEXT NOT NULL DEFAULT 'by_task';
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_max_parallel INTEGER NOT NULL DEFAULT 5;
|
||||
DROP TRIGGER IF EXISTS trg_agents_upd ON agents;
|
||||
CREATE TRIGGER trg_agents_upd BEFORE UPDATE ON agents
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
+115
-33
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -351,51 +352,111 @@ func (s *Server) runConversationSync(c *db.Conversation, msg, busyKey string) {
|
||||
_ = pg.TouchConversation(c.ID)
|
||||
}
|
||||
|
||||
// StartTriggeredRun enqueues a P3 trigger fire for agentKey. Fires for the SAME
|
||||
// agent queue up and run one at a time (FIFO) — the next only starts after the
|
||||
// previous conversation finishes. Distinct agents still run concurrently. taskID +
|
||||
// mergeable let the drainer coalesce same-task event triggers before a run starts.
|
||||
// triggerBehavior is an agent's cached P3 trigger post-processing策略 (see the
|
||||
// agents table trigger_* columns). Read once per fire in StartTriggeredRun so the
|
||||
// pump never touches the DB while holding queueMu.
|
||||
type triggerBehavior struct {
|
||||
runMode string // serial | parallel
|
||||
mergeMode string // by_task | all | none (serial 用;parallel 忽略,每条各自一个会话)
|
||||
maxParallel int // parallel 用的每 agent 并发上限;<=0=不限
|
||||
}
|
||||
|
||||
// readTriggerBehavior loads an agent's策略, falling back to safe defaults
|
||||
// (serial / by_task / 5) on any error or unknown enum value.
|
||||
func (s *Server) readTriggerBehavior(agentKey string) triggerBehavior {
|
||||
b := triggerBehavior{runMode: "serial", mergeMode: "by_task", maxParallel: 5}
|
||||
if s.m.pg == nil {
|
||||
return b
|
||||
}
|
||||
a, err := s.m.pg.GetAgentByKey(agentKey)
|
||||
if err != nil || a == nil {
|
||||
return b
|
||||
}
|
||||
if a.TriggerRunMode == "parallel" {
|
||||
b.runMode = "parallel"
|
||||
}
|
||||
switch a.TriggerMergeMode {
|
||||
case "by_task", "all", "none":
|
||||
b.mergeMode = a.TriggerMergeMode
|
||||
}
|
||||
b.maxParallel = a.TriggerMaxParallel
|
||||
return b
|
||||
}
|
||||
|
||||
// StartTriggeredRun enqueues a P3 trigger fire for agentKey and pumps the queue. The
|
||||
// agent's策略 decides concurrency + merge: serial → run one at a time (optionally
|
||||
// merging by task / all / none); parallel → run each fire in its own concurrent
|
||||
// conversation up to trigger_max_parallel. Distinct agents always run concurrently.
|
||||
func (s *Server) StartTriggeredRun(agentKey, title, message string, taskID int64, mergeable bool) {
|
||||
if s.m.pg == nil || s.chatAgentRef() == nil {
|
||||
return
|
||||
}
|
||||
cfg := s.readTriggerBehavior(agentKey) // DB read BEFORE the lock (never under queueMu)
|
||||
s.queueMu.Lock()
|
||||
s.triggerCfg[agentKey] = cfg
|
||||
s.triggerQ[agentKey] = append(s.triggerQ[agentKey], triggeredRun{agentKey: agentKey, title: title, message: message, taskID: taskID, mergeable: mergeable})
|
||||
depth := len(s.triggerQ[agentKey])
|
||||
if !s.triggerRun[agentKey] {
|
||||
s.triggerRun[agentKey] = true
|
||||
go s.drainTriggerQueue(agentKey)
|
||||
s.pumpLocked(agentKey)
|
||||
s.queueMu.Unlock()
|
||||
}
|
||||
|
||||
// pumpLocked launches queued fires for one agent up to its concurrency limit, then
|
||||
// returns. Serial → limit 1; parallel → limit = maxParallel (<=0 → unlimited). Each
|
||||
// launched run re-pumps on completion (runAndPump) to fill the freed slot. Caller
|
||||
// holds queueMu.
|
||||
func (s *Server) pumpLocked(agentKey string) {
|
||||
if s.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
cfg := s.triggerCfg[agentKey]
|
||||
limit := 1
|
||||
if cfg.runMode == "parallel" {
|
||||
if cfg.maxParallel <= 0 {
|
||||
limit = math.MaxInt
|
||||
} else {
|
||||
limit = cfg.maxParallel
|
||||
}
|
||||
}
|
||||
for s.triggerActive[agentKey] < limit && len(s.triggerQ[agentKey]) > 0 {
|
||||
item := s.nextTriggerRun(agentKey, cfg)
|
||||
s.triggerActive[agentKey]++
|
||||
go s.runAndPump(agentKey, item)
|
||||
}
|
||||
}
|
||||
|
||||
// runAndPump runs one fire to completion, then decrements the active counter and
|
||||
// re-pumps to fill the freed slot (cleaning up the agent's maps when fully idle).
|
||||
func (s *Server) runAndPump(agentKey string, item triggeredRun) {
|
||||
s.runTriggeredRun(item)
|
||||
s.queueMu.Lock()
|
||||
s.triggerActive[agentKey]--
|
||||
if s.triggerActive[agentKey] <= 0 && len(s.triggerQ[agentKey]) == 0 {
|
||||
delete(s.triggerActive, agentKey)
|
||||
delete(s.triggerQ, agentKey)
|
||||
delete(s.triggerCfg, agentKey)
|
||||
} else {
|
||||
log.Printf("[trigger] %s busy → queued (depth %d)", agentKey, depth)
|
||||
s.pumpLocked(agentKey)
|
||||
}
|
||||
s.queueMu.Unlock()
|
||||
}
|
||||
|
||||
// drainTriggerQueue runs one agent's queued trigger fires serially until the queue
|
||||
// empties, then clears the active flag. Before each run it coalesces all queued
|
||||
// finding/goal fires from the SAME task into one conversation (nextTriggerRun), so a
|
||||
// burst of a task's events becomes a single session. A panic is contained.
|
||||
func (s *Server) drainTriggerQueue(agentKey string) {
|
||||
for {
|
||||
s.queueMu.Lock()
|
||||
if len(s.triggerQ[agentKey]) == 0 || s.ctx.Err() != nil {
|
||||
delete(s.triggerQ, agentKey)
|
||||
delete(s.triggerRun, agentKey)
|
||||
s.queueMu.Unlock()
|
||||
return
|
||||
}
|
||||
item := s.nextTriggerRun(agentKey)
|
||||
s.queueMu.Unlock()
|
||||
|
||||
s.runTriggeredRun(item)
|
||||
}
|
||||
}
|
||||
|
||||
// nextTriggerRun pops the head of the agent's queue; if the head is a mergeable
|
||||
// (finding/goal) fire, it also pulls every other queued mergeable fire from the SAME
|
||||
// task and merges them into one run. Caller holds queueMu.
|
||||
func (s *Server) nextTriggerRun(agentKey string) triggeredRun {
|
||||
// nextTriggerRun pops the next run from the agent's queue per the merge mode:
|
||||
//
|
||||
// parallel / none → one head item, no merge
|
||||
// by_task → head + all queued same-task mergeable fires merged into one
|
||||
// all → the ENTIRE queue merged into one run (regardless of task/type)
|
||||
//
|
||||
// Caller holds queueMu.
|
||||
func (s *Server) nextTriggerRun(agentKey string, cfg triggerBehavior) triggeredRun {
|
||||
q := s.triggerQ[agentKey]
|
||||
if cfg.runMode == "parallel" || cfg.mergeMode == "none" {
|
||||
s.triggerQ[agentKey] = q[1:]
|
||||
return q[0]
|
||||
}
|
||||
if cfg.mergeMode == "all" {
|
||||
s.triggerQ[agentKey] = q[:0:0]
|
||||
return mergeAllRuns(q)
|
||||
}
|
||||
// by_task: head + same-task mergeable fires.
|
||||
head := q[0]
|
||||
if !head.mergeable || head.taskID == 0 {
|
||||
s.triggerQ[agentKey] = q[1:]
|
||||
@@ -435,6 +496,27 @@ func mergeTriggeredRuns(items []triggeredRun) triggeredRun {
|
||||
}
|
||||
}
|
||||
|
||||
// mergeAllRuns folds the ENTIRE queued burst into one run (merge_mode='all'),
|
||||
// regardless of task or type — a header plus each fire's message.
|
||||
func mergeAllRuns(items []triggeredRun) triggeredRun {
|
||||
if len(items) == 1 {
|
||||
return items[0]
|
||||
}
|
||||
first := items[0]
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "【本会话合并了队列中的 %d 条触发事件(不区分任务),请一并处理】\n", len(items))
|
||||
for i, it := range items {
|
||||
fmt.Fprintf(&b, "\n── 触发 %d(task#%d)──\n%s\n", i+1, it.taskID, it.message)
|
||||
}
|
||||
return triggeredRun{
|
||||
agentKey: first.agentKey,
|
||||
title: fmt.Sprintf("合并触发 · 全部 · %d 条", len(items)),
|
||||
message: b.String(),
|
||||
taskID: first.taskID,
|
||||
mergeable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// runTriggeredRun creates the conversation for one queued fire, records the human
|
||||
// turn, and runs it synchronously (blocks until the run ends). recover keeps a
|
||||
// panic from killing the drain loop and wedging the agent's queue.
|
||||
|
||||
+18
-11
@@ -304,6 +304,10 @@ type AgentDTO struct {
|
||||
RunSecs int `json:"run_seconds"`
|
||||
WebSearch bool `json:"web_search"`
|
||||
InteractiveShell bool `json:"interactive_shell"`
|
||||
// P3 触发后处理策略(仅自定义 agent 有意义)。
|
||||
TriggerRunMode string `json:"trigger_run_mode"`
|
||||
TriggerMergeMode string `json:"trigger_merge_mode"`
|
||||
TriggerMaxParallel int `json:"trigger_max_parallel"`
|
||||
// binding counts (populated only by the list endpoint) — shown on agent cards.
|
||||
McpCount int `json:"mcp_count"`
|
||||
SkillCount int `json:"skill_count"`
|
||||
@@ -312,17 +316,20 @@ type AgentDTO struct {
|
||||
|
||||
func agentDTO(a *db.Agent) AgentDTO {
|
||||
return AgentDTO{
|
||||
ID: i64s(a.ID),
|
||||
Key: a.Key,
|
||||
Name: a.Name,
|
||||
Description: a.Description,
|
||||
Role: a.Role,
|
||||
Builtin: a.Builtin,
|
||||
Enabled: a.Enabled,
|
||||
MaxTurns: a.MaxTurns,
|
||||
RunSecs: a.RunSecs,
|
||||
WebSearch: a.WebSearch,
|
||||
InteractiveShell: a.InteractiveShell,
|
||||
ID: i64s(a.ID),
|
||||
Key: a.Key,
|
||||
Name: a.Name,
|
||||
Description: a.Description,
|
||||
Role: a.Role,
|
||||
Builtin: a.Builtin,
|
||||
Enabled: a.Enabled,
|
||||
MaxTurns: a.MaxTurns,
|
||||
RunSecs: a.RunSecs,
|
||||
WebSearch: a.WebSearch,
|
||||
InteractiveShell: a.InteractiveShell,
|
||||
TriggerRunMode: a.TriggerRunMode,
|
||||
TriggerMergeMode: a.TriggerMergeMode,
|
||||
TriggerMaxParallel: a.TriggerMaxParallel,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
-34
@@ -17,14 +17,14 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
"github.com/Autumn-27/norma/memory"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
"github.com/Autumn-27/norma/transcript"
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/llmrec"
|
||||
"github.com/Autumn-27/artex/report"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
"github.com/Autumn-27/norma/memory"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
"github.com/Autumn-27/norma/transcript"
|
||||
)
|
||||
|
||||
// Server exposes the ARTEX backend over a JSON HTTP API for the shadcn/ui
|
||||
@@ -42,7 +42,7 @@ type Server struct {
|
||||
chatAgent *agent.ChatAgent // conversational runner for the chat page; nil w/o LLM
|
||||
llmCfg agent.Config // current LLM config (key not exposed)
|
||||
llmOn bool
|
||||
llmProf string // active LLM profile name (for llmrec tagging)
|
||||
llmProf string // active LLM profile name (for llmrec tagging)
|
||||
|
||||
// chatBusy guards the per-task main-agent run: the chat handler launches the
|
||||
// agent on the server's background ctx (not the request ctx) and returns
|
||||
@@ -57,15 +57,18 @@ type Server struct {
|
||||
// trigger queue — the drain goroutine simply proceeds to the next queued fire.
|
||||
chatCancel map[string]context.CancelFunc
|
||||
|
||||
// triggerQ serializes P3 trigger fires PER AGENT: multiple fires for the same
|
||||
// custom agent queue up (FIFO) and run one at a time — the next conversation
|
||||
// only starts after the previous one finishes. triggerRun marks that a drain
|
||||
// goroutine is already active for that agent key so we don't spawn two. Distinct
|
||||
// agents still run concurrently. Queue is in-memory (matches chatBusy); a restart
|
||||
// drops pending fires — the scheduler re-fires on its next tick from watermarks.
|
||||
queueMu sync.Mutex
|
||||
triggerQ map[string][]triggeredRun
|
||||
triggerRun map[string]bool
|
||||
// triggerQ buffers P3 trigger fires PER AGENT. A per-agent "pump" launches runs up
|
||||
// to a concurrency limit derived from the agent's策略: serial → limit 1 (+ optional
|
||||
// merge); parallel → limit = trigger_max_parallel (0=∞), no merge. triggerActive
|
||||
// counts in-flight runs per agent (replaces a boolean drain flag); a run's
|
||||
// completion decrements it and re-pumps to fill the freed slot. triggerCfg caches
|
||||
// the agent's last-read策略 so the pump never queries the DB while holding queueMu.
|
||||
// Distinct agents always run concurrently. Queue is in-memory (matches chatBusy); a
|
||||
// restart drops pending fires — the scheduler re-fires from watermarks next tick.
|
||||
queueMu sync.Mutex
|
||||
triggerQ map[string][]triggeredRun
|
||||
triggerActive map[string]int
|
||||
triggerCfg map[string]triggerBehavior
|
||||
|
||||
// profAgents caches the dedicated planner/worker built for a specific (non-active)
|
||||
// LLM profile, keyed by profile id, so tasks pinned to that profile share one
|
||||
@@ -100,7 +103,8 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string) *Serv
|
||||
log.Fatalf("[auth] JWT key: %v", err)
|
||||
}
|
||||
s := &Server{m: m, engine: NewEngine(m), ctx: ctx, skillDir: skillDir, jwtKey: key, chatBusy: map[string]bool{},
|
||||
chatCancel: map[string]context.CancelFunc{}, triggerQ: map[string][]triggeredRun{}, triggerRun: map[string]bool{},
|
||||
chatCancel: map[string]context.CancelFunc{}, triggerQ: map[string][]triggeredRun{},
|
||||
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
|
||||
profAgents: map[int64]*profBundle{}, profChatAgents: map[int64]*agent.ChatAgent{}}
|
||||
// per-task LLM: a task pinned to a specific profile runs on that profile's
|
||||
// dedicated planner/worker; unpinned tasks fall back to the global active pair.
|
||||
@@ -157,11 +161,11 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string) *Serv
|
||||
}
|
||||
wireAgentAugment(m.pg, s.skillDir, s.hostTools) // 可见 skills/MCP + 流量/编排 host 工具装配进 agent 工具集
|
||||
domainReg := buildDomainReg(m.Assets())
|
||||
wireTools(m.pg, domainReg) // 内置工具表:按 agent 过滤 + 覆盖描述/schema + 注入默认值
|
||||
seedPrompts(m.pg) // 内置 agent 默认提示词正文播种进 agent_prompts(仅空时)
|
||||
s.seedOrchestrationTools() // P2 跨任务编排工具 seed 进 tools 表(可按 agent 绑定)
|
||||
s.seedPythonInterpreter() // 自定义脚本工具:开机检测 python 解释器入库(仅空时)
|
||||
go newScheduler(s).Run(s.ctx) // P3 触发器调度(定时/finding/目标事件),仅自定义 agent
|
||||
wireTools(m.pg, domainReg) // 内置工具表:按 agent 过滤 + 覆盖描述/schema + 注入默认值
|
||||
seedPrompts(m.pg) // 内置 agent 默认提示词正文播种进 agent_prompts(仅空时)
|
||||
s.seedOrchestrationTools() // P2 跨任务编排工具 seed 进 tools 表(可按 agent 绑定)
|
||||
s.seedPythonInterpreter() // 自定义脚本工具:开机检测 python 解释器入库(仅空时)
|
||||
go newScheduler(s).Run(s.ctx) // P3 触发器调度(定时/finding/目标事件),仅自定义 agent
|
||||
// Fill the tool cache for any enabled MCP that has none yet (notably the
|
||||
// seeded browser MCP on first run). Async so it never blocks startup.
|
||||
go s.discoverEmptyMCPsOnStartup()
|
||||
@@ -721,8 +725,8 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
active = t.ID
|
||||
}
|
||||
list := s.m.List()
|
||||
toks, _ := s.m.PG().TokenTotalsAll() // whole-task token totals, one query for all tasks
|
||||
lastAct, _ := s.m.PG().LastActivityAll() // persisted last-activity per task, one query
|
||||
toks, _ := s.m.PG().TokenTotalsAll() // whole-task token totals, one query for all tasks
|
||||
lastAct, _ := s.m.PG().LastActivityAll() // persisted last-activity per task, one query
|
||||
goalCounts, _ := s.m.PG().GoalCountsAll() // goal progress per exploration, one query
|
||||
dtos := make([]TaskDTO, 0, len(list))
|
||||
for _, t := range list {
|
||||
@@ -1080,8 +1084,6 @@ func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, t)
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (s *Server) frontier(w http.ResponseWriter, r *http.Request) {
|
||||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||||
if t == nil {
|
||||
@@ -1402,7 +1404,6 @@ func (s *Server) activityDetail(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"detail": d})
|
||||
}
|
||||
|
||||
|
||||
func (s *Server) getTraffic(w http.ResponseWriter, r *http.Request) {
|
||||
tr := s.m.Traffic()
|
||||
if tr == nil {
|
||||
@@ -1457,15 +1458,15 @@ func (s *Server) settingsPayload() map[string]any {
|
||||
on, backend, braveKey, tavilyKey, proxy := s.m.WebSearch()
|
||||
pyStored, _, _ := s.m.pg.GetSetting(settingPythonInterp)
|
||||
return map[string]any{
|
||||
"traffic_capture": s.m.TrafficEnabled(),
|
||||
"llm_record": s.m.LLMRecordEnabled(),
|
||||
"web_search_enabled": on,
|
||||
"web_search_backend": backend,
|
||||
"brave_key_set": strings.TrimSpace(braveKey) != "",
|
||||
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
|
||||
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
|
||||
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
|
||||
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||
"traffic_capture": s.m.TrafficEnabled(),
|
||||
"llm_record": s.m.LLMRecordEnabled(),
|
||||
"web_search_enabled": on,
|
||||
"web_search_backend": backend,
|
||||
"brave_key_set": strings.TrimSpace(braveKey) != "",
|
||||
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
|
||||
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
|
||||
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
|
||||
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+41
-12
@@ -20,9 +20,9 @@ import (
|
||||
"text/template/parse"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/norma/skill"
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/norma/skill"
|
||||
)
|
||||
|
||||
// reSkillName is the base character-set pattern; validSkillName enforces
|
||||
@@ -212,24 +212,32 @@ func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// run_seconds is optional (pointer) so omitting it leaves the stored value
|
||||
// untouched rather than resetting it to 0/unlimited.
|
||||
// All fields optional (pointers) so a partial patch (e.g. the triggers tab sending
|
||||
// only trigger_* fields) leaves the untouched settings alone instead of resetting
|
||||
// max_turns/run_seconds to 0.
|
||||
var req struct {
|
||||
MaxTurns int `json:"max_turns"`
|
||||
MaxTurns *int `json:"max_turns"`
|
||||
RunSeconds *int `json:"run_seconds"`
|
||||
WebSearch *bool `json:"web_search"`
|
||||
InteractiveShell *bool `json:"interactive_shell"`
|
||||
// P3 触发后处理策略(三者一起可选,提供任一即整体写入;未提供则不动)。
|
||||
TriggerRunMode *string `json:"trigger_run_mode"`
|
||||
TriggerMergeMode *string `json:"trigger_merge_mode"`
|
||||
TriggerMaxParallel *int `json:"trigger_max_parallel"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if req.MaxTurns < 0 {
|
||||
req.MaxTurns = 0
|
||||
}
|
||||
if err := pg.SetAgentMaxTurns(a.Key, req.MaxTurns); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
if req.MaxTurns != nil {
|
||||
mt := *req.MaxTurns
|
||||
if mt < 0 {
|
||||
mt = 0
|
||||
}
|
||||
if err := pg.SetAgentMaxTurns(a.Key, mt); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.RunSeconds != nil {
|
||||
rs := *req.RunSeconds
|
||||
@@ -253,6 +261,24 @@ func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// P3 触发策略:三者作为一组写入(SetAgentTriggerBehavior 一次写三列),缺的字段用
|
||||
// 当前存量值回填,避免只传一个把另两个覆盖成默认。
|
||||
if req.TriggerRunMode != nil || req.TriggerMergeMode != nil || req.TriggerMaxParallel != nil {
|
||||
runMode, mergeMode, maxPar := a.TriggerRunMode, a.TriggerMergeMode, a.TriggerMaxParallel
|
||||
if req.TriggerRunMode != nil {
|
||||
runMode = *req.TriggerRunMode
|
||||
}
|
||||
if req.TriggerMergeMode != nil {
|
||||
mergeMode = *req.TriggerMergeMode
|
||||
}
|
||||
if req.TriggerMaxParallel != nil {
|
||||
maxPar = *req.TriggerMaxParallel
|
||||
}
|
||||
if err := pg.SetAgentTriggerBehavior(a.Key, runMode, mergeMode, maxPar); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
// rebuild the live agents so the new max_turns/run_seconds apply without a restart.
|
||||
s.cfgMu.Lock()
|
||||
cfg, on := s.llmCfg, s.llmOn
|
||||
@@ -260,7 +286,10 @@ func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if on {
|
||||
_ = s.applyLLM(cfg)
|
||||
}
|
||||
resp := map[string]any{"ok": true, "max_turns": req.MaxTurns}
|
||||
resp := map[string]any{"ok": true}
|
||||
if req.MaxTurns != nil {
|
||||
resp["max_turns"] = *req.MaxTurns
|
||||
}
|
||||
if req.RunSeconds != nil {
|
||||
resp["run_seconds"] = *req.RunSeconds
|
||||
}
|
||||
@@ -1544,7 +1573,7 @@ func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
// the OpenAI path, it falls back to the OpenAI endpoint at the stripped root.
|
||||
func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"` // "openai" | "anthropic"
|
||||
Provider string `json:"provider"` // "openai" | "anthropic"
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Proxy string `json:"proxy"`
|
||||
|
||||
@@ -258,6 +258,15 @@ export default function AgentsPage() {
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col gap-0 p-0 data-[side=right]:w-[45vw] data-[side=right]:sm:max-w-[45vw]"
|
||||
// 抽屉内的下拉/弹出层(Select 等)会 portal 到抽屉外;交互源自这些弹出层时
|
||||
// 不要把它当成「点击外部」而关闭抽屉。注意:Radix 的 onInteractOutside 里
|
||||
// e.target 是层节点本身,真正被点的元素在 e.detail.originalEvent.target。
|
||||
onInteractOutside={(e) => {
|
||||
const t = e.detail.originalEvent.target as Element | null;
|
||||
if (t?.closest?.("[data-radix-popper-content-wrapper],[data-radix-select-content],[role='listbox']")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{editing && (
|
||||
<>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AgentDetail, AgentTrigger, MCPServer, PromptVar, PromptVersion, Settings, SkillItem, Tool } from "@/lib/types";
|
||||
import type { Agent, AgentDetail, AgentTrigger, MCPServer, PromptVar, PromptVersion, Settings, SkillItem, Tool } from "@/lib/types";
|
||||
|
||||
// Traffic tools are host tools gated by the global 流量捕获 switch: bindable, but
|
||||
// only usable when capture is on. Keep this list in sync with traffic.SeedToolMetas.
|
||||
@@ -626,7 +627,7 @@ export function AgentEditor({ agentKey, onSaved }: { agentKey: string; onSaved?:
|
||||
{/* 触发(P3, 仅自定义 agent) */}
|
||||
{isCustom && (
|
||||
<TabsContent value="triggers" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
|
||||
<AgentTriggersTab agentKey={agentKey} />
|
||||
<AgentTriggersTab agentKey={agentKey} agent={detail?.agent} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
@@ -690,9 +691,30 @@ 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
|
||||
// in parallel with the base user message + auto context appended by the backend.
|
||||
function AgentTriggersTab({ agentKey }: { agentKey: string }) {
|
||||
function AgentTriggersTab({ agentKey, agent }: { agentKey: string; agent?: Agent }) {
|
||||
const [triggers, setTriggers] = React.useState<AgentTrigger[]>([]);
|
||||
const [tools, setTools] = React.useState<Tool[]>([]);
|
||||
// 触发后处理策略(每 agent);初值来自 agent detail,改动即保存。
|
||||
const [runMode, setRunMode] = React.useState<"serial" | "parallel">(agent?.trigger_run_mode ?? "serial");
|
||||
const [mergeMode, setMergeMode] = React.useState<"by_task" | "all" | "none">(agent?.trigger_merge_mode ?? "by_task");
|
||||
const [maxParallel, setMaxParallel] = React.useState(String(agent?.trigger_max_parallel ?? 5));
|
||||
React.useEffect(() => {
|
||||
setRunMode(agent?.trigger_run_mode ?? "serial");
|
||||
setMergeMode(agent?.trigger_merge_mode ?? "by_task");
|
||||
setMaxParallel(String(agent?.trigger_max_parallel ?? 5));
|
||||
}, [agent?.trigger_run_mode, agent?.trigger_merge_mode, agent?.trigger_max_parallel]);
|
||||
|
||||
async function saveBehavior(patch: {
|
||||
trigger_run_mode?: "serial" | "parallel";
|
||||
trigger_merge_mode?: "by_task" | "all" | "none";
|
||||
trigger_max_parallel?: number;
|
||||
}) {
|
||||
try {
|
||||
await api.saveAgentConfig(agentKey, patch);
|
||||
} catch (e) {
|
||||
toast.error("保存策略失败:" + (e as Error).message);
|
||||
}
|
||||
}
|
||||
const [onInterval, setOnInterval] = React.useState(false);
|
||||
const [intervalSec, setIntervalSec] = React.useState("60");
|
||||
const [onFinding, setOnFinding] = React.useState(false);
|
||||
@@ -810,10 +832,86 @@ function AgentTriggersTab({ agentKey }: { agentKey: string }) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
触发器让这个自定义 Agent 自动运行:每次触发都<b>新建一个会话并行运行</b>(在「对话」页可见)。
|
||||
触发器让这个自定义 Agent 自动运行:每次触发都<b>新建一个会话运行</b>(在「对话」页可见)。
|
||||
可多选触发条件;系统会把「本次为何触发 + 相关任务/finding/目标」自动附加到你写的基础消息后面。
|
||||
</p>
|
||||
|
||||
{/* 触发后处理策略 */}
|
||||
<div className="grid gap-3 rounded-md border p-3">
|
||||
<Label className="text-muted-foreground text-xs">触发后处理策略(决定触发如何排队/合并运行)</Label>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-xs">运行模式</Label>
|
||||
<Select
|
||||
value={runMode}
|
||||
onValueChange={(v) => {
|
||||
const rm = v as "serial" | "parallel";
|
||||
setRunMode(rm);
|
||||
saveBehavior({ trigger_run_mode: rm });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-8 w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="serial">串行(排队,一次一个)</SelectItem>
|
||||
<SelectItem value="parallel">并行(各自并发会话)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-xs">合并模式</Label>
|
||||
<Select
|
||||
value={mergeMode}
|
||||
disabled={runMode === "parallel"}
|
||||
onValueChange={(v) => {
|
||||
const mm = v as "by_task" | "all" | "none";
|
||||
setMergeMode(mm);
|
||||
saveBehavior({ trigger_merge_mode: mm });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-8 w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="by_task">按任务合并</SelectItem>
|
||||
<SelectItem value="all">全部合并成一条</SelectItem>
|
||||
<SelectItem value="none">不合并</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{runMode === "parallel" && (
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="tr-maxpar" className="text-xs">最大并发(0=不限)</Label>
|
||||
<Input
|
||||
id="tr-maxpar"
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 w-28"
|
||||
value={maxParallel}
|
||||
onChange={(e) => setMaxParallel(e.target.value)}
|
||||
onBlur={() => {
|
||||
const n = Math.max(0, Math.floor(Number(maxParallel) || 0));
|
||||
setMaxParallel(String(n));
|
||||
saveBehavior({ trigger_max_parallel: n });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{runMode === "parallel"
|
||||
? "并行:每次触发立即各开一个会话并发运行,不合并;超过最大并发的触发排队等空位。"
|
||||
: mergeMode === "by_task"
|
||||
? "串行·按任务合并:同 agent 一次跑一个;排队中同一任务的事件触发合并成一条会话。"
|
||||
: mergeMode === "all"
|
||||
? "串行·全部合并:同 agent 一次跑一个;取队列时把当前排队的所有触发合并成一条会话。"
|
||||
: "串行·不合并:同 agent 一次跑一个;每条触发各自一个会话。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 新增触发器 */}
|
||||
<div className="grid gap-3 rounded-md border p-3">
|
||||
<Label className="text-muted-foreground text-xs">新增触发器(每种条件可填各自的用户消息)</Label>
|
||||
|
||||
+12
-2
@@ -315,8 +315,18 @@ export const api = {
|
||||
updateTrigger: (id: number, t: Omit<AgentTrigger, "id" | "agent_key" | "last_fire">) =>
|
||||
patch<{ ok: boolean }>(`/triggers/${id}`, t),
|
||||
deleteTrigger: (id: number) => del<{ deleted: number }>(`/triggers/${id}`),
|
||||
saveAgentConfig: (key: string, patch: { max_turns: number; run_seconds?: number; web_search?: boolean; interactive_shell?: boolean }) =>
|
||||
put<{ ok: boolean }>(`/agents/${key}/config`, patch),
|
||||
saveAgentConfig: (
|
||||
key: string,
|
||||
patch: {
|
||||
max_turns?: number;
|
||||
run_seconds?: number;
|
||||
web_search?: boolean;
|
||||
interactive_shell?: boolean;
|
||||
trigger_run_mode?: "serial" | "parallel";
|
||||
trigger_merge_mode?: "by_task" | "all" | "none";
|
||||
trigger_max_parallel?: number;
|
||||
},
|
||||
) => put<{ ok: boolean }>(`/agents/${key}/config`, patch),
|
||||
agentPromptVersions: (key: string) => get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)),
|
||||
agentVariables: (key: string) => get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)),
|
||||
previewAgentPrompt: (key: string, template: string, sample?: Record<string, string>) =>
|
||||
|
||||
@@ -387,6 +387,10 @@ export interface Agent {
|
||||
run_seconds?: number; // worker 单次运行墙钟上限(秒);0 = 无限制
|
||||
web_search?: boolean; // 是否启用网络搜索(受系统全局开关门控)
|
||||
interactive_shell?: boolean; // 是否启用交互式 shell(持久 PTY 会话工具族)
|
||||
// P3 触发后处理策略(仅自定义 agent 有意义)
|
||||
trigger_run_mode?: "serial" | "parallel"; // 串行排队 / 每次触发各自并发一个会话
|
||||
trigger_merge_mode?: "by_task" | "all" | "none"; // 仅 serial:同任务合并 / 全部合并 / 不合并
|
||||
trigger_max_parallel?: number; // 仅 parallel:每 agent 并发上限;0=不限
|
||||
// 绑定数量(仅列表接口返回):可见 MCP / 可见 Skill / 绑定工具
|
||||
mcp_count?: number;
|
||||
skill_count?: number;
|
||||
|
||||
Reference in new issue
Block a user