From adc8e3521dda3e731ae0d973d87255e958ac8155 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:14:18 -0400 Subject: [PATCH] =?UTF-8?q?fix(llm):=20Agent=20=E7=BB=91=E5=AE=9A=E4=BC=98?= =?UTF-8?q?=E5=85=88=E4=BA=8E=E4=BB=BB=E5=8A=A1=20LLM=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务内各角色解析 LLM 的顺序由「任务链 → Agent 绑定 → 全局」改为 「Agent 绑定 → 任务链 → 全局」:显式绑定了模型的角色始终跑在该模型上, 未绑定的角色才落到任务链,任务链为空时再落到全局配置。 - 拆分 fallbackProviderForAgent 为 agentBindingProvider / globalProvider, 让三个层级可按新顺序自由组合 - current()、CompactionWindow()、taskRuntimeAvailable() 统一按新顺序解析; taskRuntimeAvailable 改为逐角色判断,有可用绑定的角色在任务链耗尽时仍可运行 - resolveTaskRoleLLM(UI 展示)与前端 mock 同步对调,避免显示与实际调用不一致 保留两处既有语义:绑定/全局路径仍返回 profileID=0,不推进任务链故障转移 (绑定自身的转移仍由 poolForBinding + llm_pool_bind_fallback 负责);未绑定 角色遇到任务链耗尽仍是硬停止,不静默降级到全局。 Claude-Session: https://claude.ai/code/session_01Q8nhYZfS4H8fsSCBJXxbSy --- server/task_admission_test.go | 69 ++++++++++++++++++++++++++ server/task_llm.go | 92 +++++++++++++++++++++++------------ server/task_llm_test.go | 27 ++++++++++ server/task_resolution.go | 24 ++++----- web/src/lib/mock/handler.ts | 12 +++-- 5 files changed, 175 insertions(+), 49 deletions(-) diff --git a/server/task_admission_test.go b/server/task_admission_test.go index 8c9c695..6a3e1ef 100644 --- a/server/task_admission_test.go +++ b/server/task_admission_test.go @@ -571,6 +571,75 @@ func TestTaskLLMResolutionRejectsInvalidExplicitProfile(t *testing.T) { } } +// An Agent binding outranks the task's LLM chain; roles left unbound keep using +// the chain, so binding one role does not move the others off it. +func TestTaskLLMResolutionPrefersAgentBindingOverTaskChain(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer m.Close() + + stamp := time.Now().UnixNano() + boundID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("bound-resolution-%d", stamp), Format: "openai", + Model: "bound-model", APIKey: "test-key", + }) + if err != nil { + t.Fatal(err) + } + chainID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("chain-resolution-%d", stamp), Format: "openai", + Model: "chain-model", APIKey: "test-key", + }) + if err != nil { + _ = m.pg.DeleteProfile(boundID) + t.Fatal(err) + } + task, err := m.CreateTaskWithOptions("binding precedence", "prefer the bound profile", db.TaskCreateOptions{ + LLMProfileIDs: []int64{chainID}, + }) + if err != nil { + _ = m.pg.DeleteProfile(boundID) + _ = m.pg.DeleteProfile(chainID) + t.Fatal(err) + } + defer func() { + _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) + _ = m.pg.SetAgentLLMProfile("worker", nil) + _ = m.pg.DeleteProfile(boundID) + _ = m.pg.DeleteProfile(chainID) + }() + + s := &Server{m: m, provByProfile: map[int64]*provEntry{}} + got, err := s.resolveTaskRoleLLM(task, "worker") + if err != nil { + t.Fatal(err) + } + if got.Source != "task_chain" || got.Model != "chain-model" { + t.Fatalf("resolution=%+v, want the task chain while no binding exists", got) + } + + if err := m.pg.SetAgentLLMProfile("worker", &boundID); err != nil { + t.Fatal(err) + } + got, err = s.resolveTaskRoleLLM(task, "worker") + if err != nil { + t.Fatal(err) + } + if !got.Available || got.Source != "agent_binding" || got.Model != "bound-model" { + t.Fatalf("resolution=%+v, want the agent binding to outrank the task chain", got) + } + + got, err = s.resolveTaskRoleLLM(task, "planner") + if err != nil { + t.Fatal(err) + } + if got.Source != "task_chain" || got.Model != "chain-model" { + t.Fatalf("resolution=%+v, want an unbound role to keep using the task chain", got) + } +} + func TestTaskLLMResolutionReportsDatabaseFailure(t *testing.T) { m, err := NewManager(t.TempDir(), "") if err != nil { diff --git a/server/task_llm.go b/server/task_llm.go index aa7be40..1dec70a 100644 --- a/server/task_llm.go +++ b/server/task_llm.go @@ -99,6 +99,12 @@ type taskLLMStreamHooks struct { transition func(taskLLMSelection, db.TaskLLMTransition, error) } +// current resolves the provider this role runs on, by precedence: +// Agent 绑定 → 任务 LLM 配置链 → 全局/环境配置。 +// 绑定优先于任务链:某个角色被显式指定了模型,就一直跑在那个模型上;绑定不存在或 +// 构建失败时才降级到任务链,任务链为空时再降级到全局配置。 +// 返回的 profile id 只有走任务链时才非零 —— streamTaskLLM 以此判断额度错误是否 +// 应该推进任务的故障转移状态(绑定/全局路径不改任务链状态,沿用既有语义)。 func (r *taskLLMRuntime) current() (*Task, int64, int64, llm.Provider, error) { taskNum, err := parseTaskID(r.taskID) if err != nil { @@ -113,21 +119,24 @@ func (r *taskLLMRuntime) current() (*Task, int64, int64, llm.Provider, error) { } r.s.syncTaskLLMState(pt) t, _ := r.s.m.Task(r.taskID) - if len(pt.LLMProfileIDs) == 0 { - prov, _, ok := r.s.fallbackProviderForAgent(r.agentKey) - if !ok { - return t, 0, pt.LLMChainRevision, nil, fmt.Errorf("task %s has no available fallback LLM provider", r.taskID) - } + if prov, _, ok := r.s.agentBindingProvider(r.agentKey); ok { return t, 0, pt.LLMChainRevision, prov, nil } - if pt.ActiveLLMProfileID == nil { - return t, 0, pt.LLMChainRevision, nil, &taskLLMError{taskID: r.taskID, chainExhausted: true, cause: errors.New("all selected profiles are quota exhausted")} + if len(pt.LLMProfileIDs) > 0 { + if pt.ActiveLLMProfileID == nil { + return t, 0, pt.LLMChainRevision, nil, &taskLLMError{taskID: r.taskID, chainExhausted: true, cause: errors.New("all selected profiles are quota exhausted")} + } + prov, _, ok := r.s.providerForProfile(*pt.ActiveLLMProfileID) + if !ok { + return t, *pt.ActiveLLMProfileID, pt.LLMChainRevision, nil, fmt.Errorf("LLM profile #%d is missing or invalid", *pt.ActiveLLMProfileID) + } + return t, *pt.ActiveLLMProfileID, pt.LLMChainRevision, prov, nil } - prov, _, ok := r.s.providerForProfile(*pt.ActiveLLMProfileID) + prov, _, ok := r.s.globalProvider() if !ok { - return t, *pt.ActiveLLMProfileID, pt.LLMChainRevision, nil, fmt.Errorf("LLM profile #%d is missing or invalid", *pt.ActiveLLMProfileID) + return t, 0, pt.LLMChainRevision, nil, fmt.Errorf("task %s has no available fallback LLM provider", r.taskID) } - return t, *pt.ActiveLLMProfileID, pt.LLMChainRevision, prov, nil + return t, 0, pt.LLMChainRevision, prov, nil } func parseTaskID(id string) (int64, error) { @@ -253,7 +262,12 @@ func streamEventCommitsOutput(event llm.StreamEvent) bool { } } +// CompactionWindow mirrors current()'s precedence so the context window always +// matches the provider the role will actually stream on. func (r *taskLLMRuntime) CompactionWindow() int { + if _, cfg, ok := r.s.agentBindingProvider(r.agentKey); ok { + return cfg.CompactionWindow() + } taskNum, err := parseTaskID(r.taskID) if err != nil { return (agent.Config{}).CompactionWindow() @@ -263,7 +277,7 @@ func (r *taskLLMRuntime) CompactionWindow() int { return (agent.Config{}).CompactionWindow() } if len(chain) == 0 { - if _, cfg, ok := r.s.fallbackProviderForAgent(r.agentKey); ok { + if _, cfg, ok := r.s.globalProvider(); ok { return cfg.CompactionWindow() } return (agent.Config{}).CompactionWindow() @@ -283,15 +297,27 @@ func (r *taskLLMRuntime) CompactionWindow() int { return minimum } -// fallbackProviderForAgent resolves the pre-existing precedence used by tasks -// without an explicit chain: the Agent's saved binding first, then the current -// global provider (which may come from the default profile or environment). -func (s *Server) fallbackProviderForAgent(agentKey string) (llm.Provider, agent.Config, bool) { - if id := s.effectiveProfileForAgent(agentKey, nil); id != nil { - if prov, cfg, ok := s.providerForProfile(*id); ok { - return s.poolForBinding(*id, prov, cfg), cfg, true - } +// agentBindingProvider resolves the profile a role is explicitly bound to +// (agents.llm_profile_id) — the highest-precedence level for task agents. ok=false +// when the role has no binding or the bound profile no longer builds, so callers +// fall through to the task chain. A bound profile stays exclusive unless +// llm_pool_bind_fallback is on, which is what poolForBinding encodes. +func (s *Server) agentBindingProvider(agentKey string) (llm.Provider, agent.Config, bool) { + id := s.effectiveProfileForAgent(agentKey, nil) + if id == nil { + return nil, agent.Config{}, false } + prov, cfg, ok := s.providerForProfile(*id) + if !ok { + return nil, agent.Config{}, false + } + return s.poolForBinding(*id, prov, cfg), cfg, true +} + +// globalProvider returns the process-wide provider (persisted active profile or +// environment config) — the last resort once a role has neither a binding nor a +// task chain. +func (s *Server) globalProvider() (llm.Provider, agent.Config, bool) { s.cfgMu.Lock() defer s.cfgMu.Unlock() if !s.llmOn || s.llmProv == nil { @@ -300,30 +326,32 @@ func (s *Server) fallbackProviderForAgent(agentKey string) (llm.Provider, agent. return s.llmProv, s.llmCfg, true } +// taskRuntimeAvailable reports whether every listed role can resolve a provider +// under the runtime precedence in current(): the role's own binding first, then +// the task chain, then global. An exhausted chain is a hard stop for unbound +// roles rather than a silent fall through to global — same as current(). func (s *Server) taskRuntimeAvailable(t *Task, agentKeys ...string) bool { - if t == nil { + if t == nil || len(agentKeys) == 0 { return false } state := t.llmStateSnapshot() + unboundReady := false if len(state.ProfileIDs) > 0 { - if state.ActiveID == nil { - return false + if state.ActiveID != nil { + _, _, unboundReady = s.providerForProfile(*state.ActiveID) } - _, _, ok := s.providerForProfile(*state.ActiveID) - return ok - } - s.cfgMu.Lock() - globallyReady := s.llmOn && s.llmProv != nil - s.cfgMu.Unlock() - if globallyReady { - return true + } else { + _, _, unboundReady = s.globalProvider() } for _, key := range agentKeys { - if _, _, ok := s.fallbackProviderForAgent(key); !ok { + if _, _, ok := s.agentBindingProvider(key); ok { + continue + } + if !unboundReady { return false } } - return len(agentKeys) > 0 + return true } func (s *Server) invalidateTaskAgents() { diff --git a/server/task_llm_test.go b/server/task_llm_test.go index a92d760..73f28f2 100644 --- a/server/task_llm_test.go +++ b/server/task_llm_test.go @@ -410,6 +410,33 @@ func TestResolvedTaskStatusKeepsExhaustedStartedTaskRunning(t *testing.T) { } } +// A role with no Agent binding falls through to the task chain, then to global — +// and an exhausted chain stays a hard stop instead of silently borrowing global. +func TestTaskRuntimeAvailableUnboundRoleFollowsChainThenGlobal(t *testing.T) { + t.Parallel() + // A nil pg means effectiveProfileForAgent finds no binding for any role, so + // every role here resolves through the chain/global levels. + s := &Server{m: &Manager{}} + task := &Task{ID: "7"} + + if s.taskRuntimeAvailable(task, "worker") { + t.Fatal("no binding, no chain and no global provider must not be runnable") + } + s.llmOn, s.llmProv = true, &scriptedLLMProvider{} + if !s.taskRuntimeAvailable(task, "worker") { + t.Fatal("global provider must serve a role with no binding and no chain") + } + if s.taskRuntimeAvailable(task) { + t.Fatal("a task with no roles requested must not report as runnable") + } + + profileID := int64(11) + task.setLLMState(&profileID, nil, []int64{profileID}, 1, "chain_exhausted", "quota exhausted") + if s.taskRuntimeAvailable(task, "worker") { + t.Fatal("exhausted chain must not fall back to the global provider") + } +} + func TestAuthoritativeTaskResolverDoesNotFallBackWhenChainUnavailable(t *testing.T) { t.Parallel() e := &Engine{} diff --git a/server/task_resolution.go b/server/task_resolution.go index 9c7d3d2..848bf64 100644 --- a/server/task_resolution.go +++ b/server/task_resolution.go @@ -42,23 +42,12 @@ func (s *Server) resolutionFromProfile(p *db.LLMProfile, source string) taskLLMR } // resolveTaskRoleLLM mirrors taskLLMRuntime.current without creating a provider: -// explicit task chain -> role Agent binding -> active/global environment config. +// role Agent binding -> explicit task chain -> active/global environment config. // Database failures are returned rather than represented as an unavailable model. func (s *Server) resolveTaskRoleLLM(t *Task, agentKey string) (taskLLMResolution, error) { if s == nil || s.m == nil || s.m.pg == nil { return taskLLMResolution{}, fmt.Errorf("database unavailable") } - state := t.llmStateSnapshot() - if len(state.ProfileIDs) > 0 { - if state.ActiveID == nil { - return taskLLMResolution{Source: "task_chain", Reason: "任务 LLM 配置链额度已耗尽"}, nil - } - p, err := s.m.pg.ProfileByID(*state.ActiveID) - if err != nil { - return taskLLMResolution{}, fmt.Errorf("load task LLM profile: %w", err) - } - return s.resolutionFromProfile(p, "task_chain"), nil - } a, err := s.m.pg.GetAgentByKey(agentKey) if err != nil { return taskLLMResolution{}, fmt.Errorf("load agent %q: %w", agentKey, err) @@ -75,6 +64,17 @@ func (s *Server) resolveTaskRoleLLM(t *Task, agentKey string) (taskLLMResolution } } } + state := t.llmStateSnapshot() + if len(state.ProfileIDs) > 0 { + if state.ActiveID == nil { + return taskLLMResolution{Source: "task_chain", Reason: "任务 LLM 配置链额度已耗尽"}, nil + } + p, err := s.m.pg.ProfileByID(*state.ActiveID) + if err != nil { + return taskLLMResolution{}, fmt.Errorf("load task LLM profile: %w", err) + } + return s.resolutionFromProfile(p, "task_chain"), nil + } p, err := s.m.pg.ActiveProfile() if err != nil { return taskLLMResolution{}, fmt.Errorf("load global LLM profile: %w", err) diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index ff5dc43..31f88fa 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -108,7 +108,14 @@ function mockProfileResolution(profileID: number | undefined, source: TaskLLMRes }; } +// Mirrors the backend precedence in server/task_resolution.go: +// Agent 绑定 → 任务 LLM 配置链 → 全局配置 → 环境配置。 function mockRoleResolution(task: Task, agentKey: "mainagent" | "planner" | "worker"): TaskLLMResolution { + const agent = D.agents.find((item) => item.key === agentKey); + if (agent?.llm_profile_id) { + const bound = mockProfileResolution(agent.llm_profile_id, "agent_binding"); + if (bound.available) return bound; + } if (task.llm_profile_ids?.length) { if (task.active_llm_profile_id === undefined) { return { @@ -122,11 +129,6 @@ function mockRoleResolution(task: Task, agentKey: "mainagent" | "planner" | "wor } return mockProfileResolution(task.active_llm_profile_id, "task_chain"); } - const agent = D.agents.find((item) => item.key === agentKey); - if (agent?.llm_profile_id) { - const bound = mockProfileResolution(agent.llm_profile_id, "agent_binding"); - if (bound.available) return bound; - } const globalProfile = D.llmProfiles.find((item) => item.is_default); if (globalProfile) return mockProfileResolution(Number(globalProfile.id), "global_profile"); return {