feat(agent): 支持为每个 agent 绑定 LLM 配置(agent 级默认模型)
按优先级 Agent 绑定 > 任务/会话指定 > 全局激活 解析每个 agent 运行的模型, 使 planner/worker/goals/mainagent/自定义 agent 可各自走不同 profile(如 plan 用 强模型、work 用弱模型)。未绑定时行为与原先完全一致。 - db: Agent 增 llm_profile_id + SetAgentLLMProfile;schema 幂等兜底 ALTER - server: 新增 effectiveProfileForAgent/providerForProfile/providerForAgent; buildPlannerWorker 让 planner/worker 各自解析 provider;provider 按 profile id 缓存以共享限速器;goals/会话链同步走绑定;绑定变更热重载 - api: agent 配置接口收 llm_profile_id(三态) + 返回可选 profile 列表 - web: agent 编辑器「配置与提示词」页新增「默认模型」下拉
This commit is contained in:
1 parent
289cf6a24f
commit
c84bba477f
10 files changed
+238
-55
No files matched your search
+16
-2
@@ -123,6 +123,7 @@ type Agent struct {
|
||||
Role string `json:"role"`
|
||||
Builtin bool `json:"builtin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LLMProfileID *int64 `json:"llm_profile_id"` // 绑定的 LLM 配置;nil=跟随任务/会话 pin,再回退全局激活
|
||||
MaxTurns int `json:"max_turns"` // 单次运行最大轮次;0=不限制
|
||||
RunSecs int `json:"run_seconds"` // worker 单次运行墙钟上限(秒);0=不限制
|
||||
WebSearch bool `json:"web_search"` // 是否启用网络搜索(受系统全局开关门控)
|
||||
@@ -141,11 +142,16 @@ type Agent struct {
|
||||
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),COALESCE(trigger_run_mode,'serial'),COALESCE(trigger_merge_mode,'by_task'),COALESCE(trigger_max_parallel,5)`
|
||||
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),llm_profile_id`
|
||||
|
||||
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, &a.TriggerRunMode, &a.TriggerMergeMode, &a.TriggerMaxParallel)
|
||||
var prof sql.NullInt64 // llm_profile_id 可空:未绑定时为 NULL
|
||||
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, &prof)
|
||||
if err == nil && prof.Valid {
|
||||
v := prof.Int64
|
||||
a.LLMProfileID = &v
|
||||
}
|
||||
return &a, err
|
||||
}
|
||||
|
||||
@@ -262,6 +268,14 @@ func (d *DB) SetAgentMaxTurns(key string, maxTurns int) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetAgentLLMProfile binds an agent to a specific LLM profile (id != nil), or clears
|
||||
// the binding (id == nil) so the agent follows the task/conversation pin, else the
|
||||
// global active profile. Precedence at runtime: agent binding → task/conv pin → active.
|
||||
func (d *DB) SetAgentLLMProfile(key string, id *int64) error {
|
||||
_, err := d.Exec(`UPDATE agents SET llm_profile_id=$1 WHERE key=$2`, id, key)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetAgentWebSearch toggles whether an agent uses network search (still gated by
|
||||
// the global web-search master switch + backend/key config).
|
||||
func (d *DB) SetAgentWebSearch(key string, on bool) error {
|
||||
|
||||
@@ -307,6 +307,8 @@ CREATE TABLE IF NOT EXISTS agents (
|
||||
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;
|
||||
-- per-agent LLM 绑定(agent 级默认模型):列自初版即在上方 CREATE 中,此 ALTER 仅为极旧库兜底(幂等)。
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL;
|
||||
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();
|
||||
|
||||
@@ -321,9 +321,10 @@ func (s *Server) runConversationSync(c *db.Conversation, msg, busyKey string) {
|
||||
delete(s.chatCancel, busyKey)
|
||||
s.chatMu.Unlock()
|
||||
}()
|
||||
// precedence: the conversation agent's own binding → this conversation's pin → global.
|
||||
ca := s.chatAgentRef()
|
||||
if c.LLMProfileID != nil {
|
||||
if pa := s.chatAgentForProfile(*c.LLMProfileID); pa != nil {
|
||||
if eff := s.effectiveProfileForAgent(c.AgentKey, c.LLMProfileID); eff != nil {
|
||||
if pa := s.chatAgentForProfile(*eff); pa != nil {
|
||||
ca = pa
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +304,7 @@ type AgentDTO struct {
|
||||
RunSecs int `json:"run_seconds"`
|
||||
WebSearch bool `json:"web_search"`
|
||||
InteractiveShell bool `json:"interactive_shell"`
|
||||
LLMProfileID *int64 `json:"llm_profile_id"` // 绑定的 LLM 配置;null=跟随任务/全局
|
||||
// P3 触发后处理策略(仅自定义 agent 有意义)。
|
||||
TriggerRunMode string `json:"trigger_run_mode"`
|
||||
TriggerMergeMode string `json:"trigger_merge_mode"`
|
||||
@@ -327,6 +328,7 @@ func agentDTO(a *db.Agent) AgentDTO {
|
||||
RunSecs: a.RunSecs,
|
||||
WebSearch: a.WebSearch,
|
||||
InteractiveShell: a.InteractiveShell,
|
||||
LLMProfileID: a.LLMProfileID,
|
||||
TriggerRunMode: a.TriggerRunMode,
|
||||
TriggerMergeMode: a.TriggerMergeMode,
|
||||
TriggerMaxParallel: a.TriggerMaxParallel,
|
||||
|
||||
+9
-4
@@ -62,11 +62,16 @@ func (s *Server) createGoals(ctx context.Context, t *Task, emit func(db.Activity
|
||||
return specs
|
||||
}
|
||||
|
||||
// goalsLLMConfig resolves the LLM config for goal decomposition: the task's pinned
|
||||
// profile if any, else the global active profile (same source the engine runs on).
|
||||
// goalsLLMConfig resolves the LLM config for goal decomposition by the standard
|
||||
// precedence: the goals agent's own binding → the task's pinned profile → the global
|
||||
// active profile (same source the engine runs on).
|
||||
func (s *Server) goalsLLMConfig(t *Task) (agent.Config, bool) {
|
||||
if t != nil && t.LLMProfileID != nil {
|
||||
if cfg, ok := s.loadProfileConfig(*t.LLMProfileID); ok {
|
||||
var pin *int64
|
||||
if t != nil {
|
||||
pin = t.LLMProfileID
|
||||
}
|
||||
if eff := s.effectiveProfileForAgent("goals", pin); eff != nil {
|
||||
if cfg, ok := s.loadProfileConfig(*eff); ok {
|
||||
return cfg, true
|
||||
}
|
||||
}
|
||||
|
||||
+123
-42
@@ -78,6 +78,15 @@ type Server struct {
|
||||
profMu sync.Mutex
|
||||
profAgents map[int64]*profBundle
|
||||
profChatAgents map[int64]*agent.ChatAgent // per-profile ChatAgent cache (chat page)
|
||||
|
||||
// provByProfile caches ONE provider per LLM profile id so every agent bound or
|
||||
// pinned to the same profile shares a single provider instance — hence one rate
|
||||
// limiter. Separate from profAgents (whole planner/worker pairs). The globally-active
|
||||
// provider (built in applyLLM) lives OUTSIDE this cache, so binding an agent to the
|
||||
// currently-active profile builds a second instance — harmless (that binding equals
|
||||
// leaving it unset), just not deduped. Cleared alongside profAgents on profile edits.
|
||||
provCacheMu sync.Mutex
|
||||
provByProfile map[int64]*provEntry
|
||||
}
|
||||
|
||||
// profBundle is a planner/worker pair built from one LLM profile.
|
||||
@@ -86,6 +95,12 @@ type profBundle struct {
|
||||
wk *agent.Worker
|
||||
}
|
||||
|
||||
// provEntry is a cached provider + its config for one LLM profile id.
|
||||
type provEntry struct {
|
||||
prov llm.Provider
|
||||
cfg agent.Config
|
||||
}
|
||||
|
||||
// triggeredRun is one queued P3 trigger fire awaiting its turn for an agent.
|
||||
// taskID + mergeable let the drainer coalesce several event triggers (finding/goal)
|
||||
// from the SAME task into one conversation before it starts (interval fires don't merge).
|
||||
@@ -105,7 +120,8 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
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{},
|
||||
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
|
||||
profAgents: map[int64]*profBundle{}, profChatAgents: map[int64]*agent.ChatAgent{}}
|
||||
profAgents: map[int64]*profBundle{}, profChatAgents: map[int64]*agent.ChatAgent{},
|
||||
provByProfile: map[int64]*provEntry{}}
|
||||
// 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.
|
||||
s.engine.SetAgentResolver(func(t *Task) (*agent.Planner, *agent.Worker) {
|
||||
@@ -307,21 +323,24 @@ func (s *Server) webSearchFor(key string) agent.WebSearchOpts {
|
||||
return o
|
||||
}
|
||||
|
||||
// buildPlannerWorker builds a planner+worker pair on an already-constructed provider
|
||||
// + cfg, with all engine callbacks / proxy / web-search / memory wiring. Shared by the
|
||||
// global apply path (applyLLM) and the per-profile path (agentsForProfile), so a task
|
||||
// pinned to a specific profile behaves identically to the active one — just a different LLM.
|
||||
func (s *Server) buildPlannerWorker(prov llm.Provider, cfg agent.Config) (*agent.Planner, *agent.Worker) {
|
||||
// buildPlannerWorker builds a planner+worker pair. Each agent resolves its OWN LLM by
|
||||
// precedence agent-binding → pin → the passed global fallback (gProv/gCfg), so planner
|
||||
// and worker can run on different models (e.g. a stronger planner, a cheaper worker).
|
||||
// pinID is the task's pinned profile (nil on the global active path). With no agent
|
||||
// binding and no pin, both fall back to gProv/gCfg — identical to the previous single-
|
||||
// provider behavior. Shared by applyLLM (global) and agentsForProfile (per-task pin).
|
||||
func (s *Server) buildPlannerWorker(pinID *int64, gProv llm.Provider, gCfg agent.Config) (*agent.Planner, *agent.Worker) {
|
||||
tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts")) // raw LLM conversation logs
|
||||
win := cfg.CompactionWindow() // context window in tokens (compaction)
|
||||
// traffic host tools flow through ToolAugment for every agent and are filtered by
|
||||
// the tools-table binding (default = worker), so worker behavior is unchanged.
|
||||
wk := agent.NewWorker(prov, cfg.Model, s.m.dir, tx, win, s.agentMaxTurns("worker"))
|
||||
wProv, wCfg := s.providerForAgent("worker", pinID, gProv, gCfg)
|
||||
wk := agent.NewWorker(wProv, wCfg.Model, s.m.dir, tx, wCfg.CompactionWindow(), s.agentMaxTurns("worker"))
|
||||
wk.SetRunTimeout(time.Duration(s.agentRunSeconds("worker")) * time.Second)
|
||||
wk.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
|
||||
wk.SetMemory(memory.NewStore(filepath.Join(s.m.dir, "memory")))
|
||||
wk.SetWebSearch(s.webSearchFor("worker"))
|
||||
pl := agent.NewPlanner(prov, cfg.Model, s.m.dir, tx, win, s.agentMaxTurns("planner"))
|
||||
pProv, pCfg := s.providerForAgent("planner", pinID, gProv, gCfg)
|
||||
pl := agent.NewPlanner(pProv, pCfg.Model, s.m.dir, tx, pCfg.CompactionWindow(), s.agentMaxTurns("planner"))
|
||||
pl.SetKillWork(s.engine.KillWork) // planner kill_work → terminate a running work
|
||||
pl.SetSteerWork(s.engine.SteerWork) // planner steer_work → inject mid-run course-correction
|
||||
pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) // WebFetch through the recording proxy
|
||||
@@ -341,13 +360,17 @@ func (s *Server) applyLLM(cfg agent.Config) error {
|
||||
profName := s.llmProf
|
||||
s.cfgMu.Unlock()
|
||||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, s.m.LLMRecordEnabled)
|
||||
pl, wk := s.buildPlannerWorker(prov, cfg)
|
||||
pl, wk := s.buildPlannerWorker(nil, prov, cfg)
|
||||
s.engine.UseLLM(pl, wk)
|
||||
|
||||
tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts"))
|
||||
win := cfg.CompactionWindow()
|
||||
// mainagent resolves its own binding (→ global fallback); chat stays the GLOBAL
|
||||
// fallback since one ChatAgent serves many agent keys — its per-agent binding is
|
||||
// resolved at Chat time (runConversationSync → chatAgentForProfile).
|
||||
mProv, mCfg := s.providerForAgent("mainagent", nil, prov, cfg)
|
||||
s.cfgMu.Lock()
|
||||
s.mainAgent = agent.NewMainAgent(prov, cfg.Model, s.m.dir, tx, win, s.agentMaxTurns("mainagent"))
|
||||
s.mainAgent = agent.NewMainAgent(mProv, mCfg.Model, s.m.dir, tx, mCfg.CompactionWindow(), s.agentMaxTurns("mainagent"))
|
||||
s.mainAgent.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) // WebFetch through the recording proxy
|
||||
s.mainAgent.SetWebSearch(s.webSearchFor("mainagent"))
|
||||
// chat agent serves MANY custom agents by key → it holds the GLOBAL opts
|
||||
@@ -384,31 +407,88 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
|
||||
return cfg, true
|
||||
}
|
||||
|
||||
// agentsForProfile returns the dedicated planner/worker for a specific LLM profile,
|
||||
// built + cached on first use (tasks on the same profile share one provider + limiter).
|
||||
// nil,nil when the profile is invalid → the caller falls back to the global active pair.
|
||||
func (s *Server) agentsForProfile(id int64) (*agent.Planner, *agent.Worker) {
|
||||
s.profMu.Lock()
|
||||
defer s.profMu.Unlock()
|
||||
if b := s.profAgents[id]; b != nil {
|
||||
return b.pl, b.wk
|
||||
// effectiveProfileForAgent resolves the LLM profile id an agent should run on, by
|
||||
// precedence: agent binding (agents.llm_profile_id) → pin (task/conversation) → nil
|
||||
// (caller falls back to the global active profile). A binding to a deleted profile
|
||||
// can't happen (FK ON DELETE SET NULL); an otherwise-invalid one is dropped downstream
|
||||
// by loadProfileConfig, letting the caller fall back.
|
||||
func (s *Server) effectiveProfileForAgent(agentKey string, pinID *int64) *int64 {
|
||||
if s.m.pg != nil && agentKey != "" {
|
||||
if a, _ := s.m.pg.GetAgentByKey(agentKey); a != nil && a.LLMProfileID != nil {
|
||||
return a.LLMProfileID
|
||||
}
|
||||
}
|
||||
return pinID
|
||||
}
|
||||
|
||||
// providerForProfile returns a cached provider+cfg for a profile id, so every agent
|
||||
// bound/pinned to the same profile shares one provider instance (one rate limiter).
|
||||
// ok=false when the profile is missing/invalid → caller falls back to the global pair.
|
||||
func (s *Server) providerForProfile(id int64) (llm.Provider, agent.Config, bool) {
|
||||
s.provCacheMu.Lock()
|
||||
if e := s.provByProfile[id]; e != nil {
|
||||
s.provCacheMu.Unlock()
|
||||
return e.prov, e.cfg, true
|
||||
}
|
||||
s.provCacheMu.Unlock()
|
||||
cfg, ok := s.loadProfileConfig(id)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
return nil, agent.Config{}, false
|
||||
}
|
||||
prov, err := cfg.NewProvider()
|
||||
if err != nil {
|
||||
log.Printf("[engine] build provider for LLM profile %d failed: %v", id, err)
|
||||
return nil, nil
|
||||
return nil, agent.Config{}, false
|
||||
}
|
||||
// Wrap with recorder, tagged with this profile's name.
|
||||
if p, _ := s.m.pg.ProfileByID(id); p != nil {
|
||||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, s.m.LLMRecordEnabled)
|
||||
}
|
||||
pl, wk := s.buildPlannerWorker(prov, cfg)
|
||||
s.profAgents[id] = &profBundle{pl: pl, wk: wk}
|
||||
log.Printf("[engine] built dedicated planner/worker for LLM profile %d (%s / %s)", id, cfg.Provider(), cfg.Model)
|
||||
s.provCacheMu.Lock()
|
||||
if e := s.provByProfile[id]; e != nil { // lost the race → keep the winner
|
||||
prov, cfg = e.prov, e.cfg
|
||||
} else {
|
||||
s.provByProfile[id] = &provEntry{prov: prov, cfg: cfg}
|
||||
log.Printf("[engine] built provider for LLM profile %d (%s / %s)", id, cfg.Provider(), cfg.Model)
|
||||
}
|
||||
s.provCacheMu.Unlock()
|
||||
return prov, cfg, true
|
||||
}
|
||||
|
||||
// providerForAgent returns the provider+cfg one agent should run on: its bound/pinned
|
||||
// profile if that resolves, else the passed global fallback (gProv/gCfg).
|
||||
func (s *Server) providerForAgent(agentKey string, pinID *int64, gProv llm.Provider, gCfg agent.Config) (llm.Provider, agent.Config) {
|
||||
if eff := s.effectiveProfileForAgent(agentKey, pinID); eff != nil {
|
||||
if prov, cfg, ok := s.providerForProfile(*eff); ok {
|
||||
return prov, cfg
|
||||
}
|
||||
}
|
||||
return gProv, gCfg
|
||||
}
|
||||
|
||||
// agentsForProfile returns the dedicated planner/worker for a task pinned to a specific
|
||||
// LLM profile, built + cached on first use (tasks on the same pin share one pair). Each
|
||||
// agent still honors its own binding first (via buildPlannerWorker), falling back to this
|
||||
// pinned profile. nil,nil when the profile is invalid → caller uses the global active pair.
|
||||
func (s *Server) agentsForProfile(id int64) (*agent.Planner, *agent.Worker) {
|
||||
s.profMu.Lock()
|
||||
b := s.profAgents[id]
|
||||
s.profMu.Unlock()
|
||||
if b != nil {
|
||||
return b.pl, b.wk
|
||||
}
|
||||
prov, cfg, ok := s.providerForProfile(id)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
pl, wk := s.buildPlannerWorker(&id, prov, cfg)
|
||||
s.profMu.Lock()
|
||||
if ex := s.profAgents[id]; ex != nil { // lost the race → keep the winner
|
||||
pl, wk = ex.pl, ex.wk
|
||||
} else {
|
||||
s.profAgents[id] = &profBundle{pl: pl, wk: wk}
|
||||
}
|
||||
s.profMu.Unlock()
|
||||
return pl, wk
|
||||
}
|
||||
|
||||
@@ -416,40 +496,41 @@ func (s *Server) agentsForProfile(id int64) (*agent.Planner, *agent.Worker) {
|
||||
// per profile id. Returns nil if the profile is missing or has no API key.
|
||||
func (s *Server) chatAgentForProfile(id int64) *agent.ChatAgent {
|
||||
s.profMu.Lock()
|
||||
defer s.profMu.Unlock()
|
||||
if ca := s.profChatAgents[id]; ca != nil {
|
||||
return ca
|
||||
cached := s.profChatAgents[id]
|
||||
s.profMu.Unlock()
|
||||
if cached != nil {
|
||||
return cached
|
||||
}
|
||||
cfg, ok := s.loadProfileConfig(id)
|
||||
prov, cfg, ok := s.providerForProfile(id)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
prov, err := cfg.NewProvider()
|
||||
if err != nil {
|
||||
log.Printf("[chat] build provider for LLM profile %d failed: %v", id, err)
|
||||
return nil
|
||||
}
|
||||
// Wrap with recorder, tagged with this profile's name.
|
||||
if p, _ := s.m.pg.ProfileByID(id); p != nil {
|
||||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, s.m.LLMRecordEnabled)
|
||||
}
|
||||
tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts"))
|
||||
win := cfg.CompactionWindow()
|
||||
ca := agent.NewChatAgent(prov, cfg.Model, s.m.dir, tx, win)
|
||||
ca := agent.NewChatAgent(prov, cfg.Model, s.m.dir, tx, cfg.CompactionWindow())
|
||||
ca.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
|
||||
ca.SetWebSearch(s.m.WebSearchOpts())
|
||||
ca.SetGuard(s.chatGuard())
|
||||
s.profChatAgents[id] = ca
|
||||
s.profMu.Lock()
|
||||
if ex := s.profChatAgents[id]; ex != nil { // lost the race → keep the winner
|
||||
ca = ex
|
||||
} else {
|
||||
s.profChatAgents[id] = ca
|
||||
}
|
||||
s.profMu.Unlock()
|
||||
return ca
|
||||
}
|
||||
|
||||
// invalidateProfileAgents drops the per-profile agent cache so a profile save/activate/
|
||||
// delete rebuilds pinned tasks' planner/worker on their next round.
|
||||
// invalidateProfileAgents drops the per-profile agent + provider caches so a profile
|
||||
// save/activate/delete — or an agent's binding change — rebuilds pinned tasks' planner/
|
||||
// worker (and re-resolves each agent's bound model) on their next round.
|
||||
func (s *Server) invalidateProfileAgents() {
|
||||
s.profMu.Lock()
|
||||
s.profAgents = map[int64]*profBundle{}
|
||||
s.profChatAgents = map[int64]*agent.ChatAgent{}
|
||||
s.profMu.Unlock()
|
||||
s.provCacheMu.Lock()
|
||||
s.provByProfile = map[int64]*provEntry{}
|
||||
s.provCacheMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) mainAgentRef() *agent.MainAgent {
|
||||
|
||||
+37
-1
@@ -220,6 +220,8 @@ func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) {
|
||||
RunSeconds *int `json:"run_seconds"`
|
||||
WebSearch *bool `json:"web_search"`
|
||||
InteractiveShell *bool `json:"interactive_shell"`
|
||||
// llm_profile_id 三态:字段缺省=不动;显式 null=解绑(跟随任务/全局);数字=绑定该 profile。
|
||||
LLMProfileID json.RawMessage `json:"llm_profile_id"`
|
||||
// P3 触发后处理策略(三者一起可选,提供任一即整体写入;未提供则不动)。
|
||||
TriggerRunMode *string `json:"trigger_run_mode"`
|
||||
TriggerMergeMode *string `json:"trigger_merge_mode"`
|
||||
@@ -229,6 +231,25 @@ func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
profileChanged := false
|
||||
if req.LLMProfileID != nil { // key present (数字 或 null)
|
||||
var id *int64
|
||||
if err := json.Unmarshal(req.LLMProfileID, &id); err != nil {
|
||||
writeErr(w, 400, "llm_profile_id 格式错误")
|
||||
return
|
||||
}
|
||||
if id != nil { // 绑定:校验目标 profile 有效
|
||||
if _, ok := s.loadProfileConfig(*id); !ok {
|
||||
writeErr(w, 400, "指定的 LLM 配置不存在或无效")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := pg.SetAgentLLMProfile(a.Key, id); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
profileChanged = true
|
||||
}
|
||||
if req.MaxTurns != nil {
|
||||
mt := *req.MaxTurns
|
||||
if mt < 0 {
|
||||
@@ -279,7 +300,12 @@ func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// rebuild the live agents so the new max_turns/run_seconds apply without a restart.
|
||||
// an agent's LLM binding change also invalidates pinned-task / per-profile caches
|
||||
// so their next round re-resolves each agent's bound model.
|
||||
if profileChanged {
|
||||
s.invalidateProfileAgents()
|
||||
}
|
||||
// rebuild the live agents so the new max_turns/run_seconds/binding apply without a restart.
|
||||
s.cfgMu.Lock()
|
||||
cfg, on := s.llmCfg, s.llmOn
|
||||
s.cfgMu.Unlock()
|
||||
@@ -313,9 +339,19 @@ func (s *Server) pgGetAgent(w http.ResponseWriter, r *http.Request) {
|
||||
if sk == nil {
|
||||
sk = []string{}
|
||||
}
|
||||
// 可选 LLM 配置列表(id/name/model/是否默认),供前端渲染 "默认模型" 下拉;当前绑定见 agent.llm_profile_id。
|
||||
profs, _ := pg.ListProfiles()
|
||||
llmProfiles := make([]map[string]any, 0, len(profs))
|
||||
for _, p := range profs {
|
||||
llmProfiles = append(llmProfiles, map[string]any{
|
||||
"id": p.ID, "name": p.Name, "model": p.Model, "is_default": p.IsDefault,
|
||||
})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"agent": agentDTO(a), "prompt": cur, "variables": vars, "versions": vers,
|
||||
"visibility": map[string]any{"mcp": mcp, "skill": sk},
|
||||
"llm_profiles": llmProfiles, // 可绑定的 LLM 配置候选
|
||||
|
||||
"wrapup_prompt": a.WrapupPrompt, // 已保存的收尾提示词(空=用内置默认)
|
||||
"wrapup_default": agent.WrapupDefault(a.Key), // 内置默认(供占位/恢复默认)
|
||||
"wrapup_max_turns": a.WrapupMaxTurns, // 已保存的收尾轮数(0=用内置默认)
|
||||
|
||||
@@ -51,6 +51,9 @@ export function AgentEditor({ agentKey, onSaved }: { agentKey: string; onSaved?:
|
||||
const [preview, setPreview] = React.useState("");
|
||||
const [maxTurns, setMaxTurns] = React.useState("0");
|
||||
const [runSecs, setRunSecs] = React.useState("600");
|
||||
// "" = 跟随(未绑定);否则为 profile id 字符串
|
||||
const [llmProfileId, setLlmProfileId] = React.useState("");
|
||||
const [llmProfiles, setLlmProfiles] = React.useState<NonNullable<AgentDetail["llm_profiles"]>>([]);
|
||||
const [webSearch, setWebSearch] = React.useState(false);
|
||||
const [interactiveShell, setInteractiveShell] = React.useState(false);
|
||||
const [wrapup, setWrapup] = React.useState("");
|
||||
@@ -87,6 +90,8 @@ export function AgentEditor({ agentKey, onSaved }: { agentKey: string; onSaved?:
|
||||
setSkillVisible(d.visibility?.skill ?? []);
|
||||
setMaxTurns(String(d.agent?.max_turns ?? 0));
|
||||
setRunSecs(String(d.agent?.run_seconds ?? 600));
|
||||
setLlmProfileId(d.agent?.llm_profile_id != null ? String(d.agent.llm_profile_id) : "");
|
||||
setLlmProfiles(d.llm_profiles ?? []);
|
||||
setWebSearch(!!d.agent?.web_search);
|
||||
setInteractiveShell(!!d.agent?.interactive_shell);
|
||||
setWrapup(d.wrapup_prompt ?? "");
|
||||
@@ -173,9 +178,17 @@ export function AgentEditor({ agentKey, onSaved }: { agentKey: string; onSaved?:
|
||||
}
|
||||
async function saveConfig() {
|
||||
try {
|
||||
const n = Math.max(0, Math.floor(Number(maxTurns) || 0));
|
||||
const rs = Math.max(0, Math.floor(Number(runSecs) || 0));
|
||||
await api.saveAgentConfig(agentKey, { max_turns: n, run_seconds: rs, web_search: webSearch, interactive_shell: interactiveShell });
|
||||
// 只提交本 agent 实际展示的字段,避免把未显示项(如 goals 的 max_turns)覆盖成默认。
|
||||
const patch: Parameters<typeof api.saveAgentConfig>[1] = {
|
||||
llm_profile_id: llmProfileId === "" ? null : Number(llmProfileId),
|
||||
};
|
||||
if (showConfig) {
|
||||
patch.max_turns = Math.max(0, Math.floor(Number(maxTurns) || 0));
|
||||
patch.run_seconds = Math.max(0, Math.floor(Number(runSecs) || 0));
|
||||
}
|
||||
if (showWebSearch) patch.web_search = webSearch;
|
||||
if (showInteractiveShell) patch.interactive_shell = interactiveShell;
|
||||
await api.saveAgentConfig(agentKey, patch);
|
||||
toast.success("已保存运行配置(立即生效)");
|
||||
reload();
|
||||
} catch (e) {
|
||||
@@ -248,6 +261,8 @@ export function AgentEditor({ agentKey, onSaved }: { agentKey: string; onSaved?:
|
||||
const showWebSearch = agentKey !== "goals";
|
||||
// interactive shell (持久 PTY 会话工具族) 同样对除 goals 外的 agent 开放;无全局门控。
|
||||
const showInteractiveShell = agentKey !== "goals";
|
||||
// 每个 agent(含 goals/mainagent)都跑在某个 LLM 上,故「默认模型」绑定对所有 agent 开放。
|
||||
const showLLM = true;
|
||||
// triggers (P3) only attach to custom agents.
|
||||
const isCustom = !!detail && !detail.agent?.builtin;
|
||||
|
||||
@@ -265,8 +280,31 @@ export function AgentEditor({ agentKey, onSaved }: { agentKey: string; onSaved?:
|
||||
{/* 配置 + 提示词 */}
|
||||
<TabsContent value="prompt" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
|
||||
<div className="grid gap-4">
|
||||
{(showConfig || showWebSearch || showInteractiveShell) && (
|
||||
{(showLLM || showConfig || showWebSearch || showInteractiveShell) && (
|
||||
<div className="grid gap-3 rounded-md border p-3">
|
||||
{showLLM && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="llm-profile" className="text-xs">默认模型(LLM 配置)</Label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select value={llmProfileId || "__follow__"} onValueChange={(v) => setLlmProfileId(v === "__follow__" ? "" : v)}>
|
||||
<SelectTrigger id="llm-profile" className="h-8 w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__follow__">跟随任务 / 全局激活配置</SelectItem>
|
||||
{llmProfiles.map((p) => (
|
||||
<SelectItem key={p.id} value={String(p.id)}>
|
||||
{p.name}({p.model}){p.is_default ? " · 默认" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground max-w-md text-xs">
|
||||
为该 Agent 绑定固定 LLM 配置(点「保存配置」生效)。优先级:Agent 绑定 > 任务/会话指定 > 全局激活。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
{showConfig && (
|
||||
<>
|
||||
|
||||
@@ -421,6 +421,7 @@ export const api = {
|
||||
saveAgentConfig: (
|
||||
key: string,
|
||||
patch: {
|
||||
llm_profile_id?: number | null; // number=绑定;null=解绑(跟随任务/全局);缺省=不动
|
||||
max_turns?: number;
|
||||
run_seconds?: number;
|
||||
web_search?: boolean;
|
||||
|
||||
@@ -456,6 +456,7 @@ export interface Agent {
|
||||
role: string;
|
||||
builtin: boolean;
|
||||
enabled: boolean;
|
||||
llm_profile_id?: number | null; // 绑定的 LLM 配置;null/absent = 跟随任务/会话/全局
|
||||
max_turns?: number; // 0 = 无限制
|
||||
run_seconds?: number; // worker 单次运行墙钟上限(秒);0 = 无限制
|
||||
web_search?: boolean; // 是否启用网络搜索(受系统全局开关门控)
|
||||
@@ -490,6 +491,8 @@ export interface AgentDetail {
|
||||
variables: PromptVar[];
|
||||
versions: PromptVersion[];
|
||||
visibility: { mcp: number[]; skill: string[] };
|
||||
// 可绑定的 LLM 配置候选(供「默认模型」下拉);当前绑定见 agent.llm_profile_id
|
||||
llm_profiles?: { id: number; name: string; model: string; is_default: boolean }[];
|
||||
wrapup_prompt?: string; // 已保存的收尾提示词(空=用内置默认)
|
||||
wrapup_default?: string; // 内置默认收尾提示词(占位/恢复默认)
|
||||
wrapup_max_turns?: number; // 已保存的收尾轮数(0=用内置默认)
|
||||
|
||||
Reference in new issue
Block a user