diff --git a/db/config.go b/db/config.go index 0322d83..b33096b 100644 --- a/db/config.go +++ b/db/config.go @@ -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 { diff --git a/db/schema.sql b/db/schema.sql index d079854..2c3ba40 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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(); diff --git a/server/conversations.go b/server/conversations.go index dc9fa9e..aa49e16 100644 --- a/server/conversations.go +++ b/server/conversations.go @@ -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 } } diff --git a/server/dto.go b/server/dto.go index 44385e4..b7b8f35 100644 --- a/server/dto.go +++ b/server/dto.go @@ -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, diff --git a/server/goals.go b/server/goals.go index fcc008a..b6faf40 100644 --- a/server/goals.go +++ b/server/goals.go @@ -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 } } diff --git a/server/server.go b/server/server.go index 0ec0271..635ab8b 100644 --- a/server/server.go +++ b/server/server.go @@ -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 { diff --git a/server/server_mgmt.go b/server/server_mgmt.go index 1b8301e..254a5eb 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -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=用内置默认) diff --git a/web/src/components/agent-editor.tsx b/web/src/components/agent-editor.tsx index f582a1d..54126b6 100644 --- a/web/src/components/agent-editor.tsx +++ b/web/src/components/agent-editor.tsx @@ -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>([]); 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[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?: {/* 配置 + 提示词 */}
- {(showConfig || showWebSearch || showInteractiveShell) && ( + {(showLLM || showConfig || showWebSearch || showInteractiveShell) && (
+ {showLLM && ( +
+ +
+ + + 为该 Agent 绑定固定 LLM 配置(点「保存配置」生效)。优先级:Agent 绑定 > 任务/会话指定 > 全局激活。 + +
+
+ )}
{showConfig && ( <> diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 80fc47f..db5ffad 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 27ec3ec..211d974 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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=用内置默认)