fix(llm): Agent 绑定优先于任务 LLM 配置链
任务内各角色解析 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
This commit is contained in:
1 parent
476a44577c
commit
adc8e3521d
5 files changed
+175
-49
No files matched your search
@@ -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 {
|
||||
|
||||
+60
-32
@@ -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() {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
+12
-12
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user