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:
Autumn-27andClaude Opus 4.8 committed 2026-08-08 04:23:09 -04:00
1 parent f9716269ea
commit 6d8fa5bc48
10 files changed
+374 -98

No files matched your search

+35 -34
View File
@@ -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);对之后启动的任务生效
}
}