Files
ARTEX/server/task_llm.go
T
Autumn-27 adc8e3521d 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
2026-08-22 06:14:18 -04:00

491 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"iter"
"log"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/llmrec"
"github.com/Autumn-27/norma/llm"
"github.com/Autumn-27/norma/memory"
"github.com/Autumn-27/norma/transcript"
)
type taskAgentBundle struct {
runtime *taskLLMRuntime // goal decomposition runtime
plannerRuntime *taskLLMRuntime
workerRuntime *taskLLMRuntime
mainRuntime *taskLLMRuntime
pl *agent.Planner
wk *agent.Worker
main *agent.MainAgent
}
type llmAuditProfile struct {
ID int64 `json:"id"`
Name string `json:"name"`
Format string `json:"format"`
Model string `json:"model"`
}
type llmTransitionAudit struct {
Mode string `json:"mode"` // automatic | manual | exhausted
Reason string `json:"reason"`
Previous *llmAuditProfile `json:"previous,omitempty"`
Next *llmAuditProfile `json:"next,omitempty"`
}
type llmActivityMetadata struct {
LLMTransition llmTransitionAudit `json:"llm_transition"`
}
type taskLLMRuntime struct {
s *Server
taskID string
agentKey string
}
type taskLLMError struct {
taskID string
chainExhausted bool
cause error
}
func (e *taskLLMError) Error() string {
if e.chainExhausted {
return fmt.Sprintf("task %s LLM profile chain exhausted: %v", e.taskID, e.cause)
}
return e.cause.Error()
}
func (e *taskLLMError) Unwrap() error { return e.cause }
func isTaskLLMRuntimeError(err error) bool {
var target *taskLLMError
return errors.As(err, &target)
}
func isTaskLLMChainExhausted(err error) bool {
var target *taskLLMError
return errors.As(err, &target) && target.chainExhausted
}
// isQuotaExhaustedError is intentionally strict. A generic 429, auth error,
// network failure, or 5xx does not rotate providers; the response must explicitly
// identify quota, credits, billing balance, or payment exhaustion.
func isQuotaExhaustedError(err error) bool {
return err != nil && agent.IsQuotaExhaustedMessage(err.Error())
}
type taskLLMSelection struct {
task *Task
profileID int64
revision int64
provider llm.Provider
}
type taskLLMStreamHooks struct {
current func() (taskLLMSelection, error)
exhaust func(taskLLMSelection, error) (db.TaskLLMTransition, error)
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 {
return nil, 0, 0, nil, err
}
pt, err := r.s.m.pg.GetTask(taskNum)
if err != nil {
return nil, 0, 0, nil, err
}
if pt == nil {
return nil, 0, 0, nil, fmt.Errorf("task %s not found", r.taskID)
}
r.s.syncTaskLLMState(pt)
t, _ := r.s.m.Task(r.taskID)
if prov, _, ok := r.s.agentBindingProvider(r.agentKey); ok {
return t, 0, pt.LLMChainRevision, prov, nil
}
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.globalProvider()
if !ok {
return t, 0, pt.LLMChainRevision, nil, fmt.Errorf("task %s has no available fallback LLM provider", r.taskID)
}
return t, 0, pt.LLMChainRevision, prov, nil
}
func parseTaskID(id string) (int64, error) {
n, err := strconv.ParseInt(id, 10, 64)
if err != nil || n <= 0 {
return 0, fmt.Errorf("invalid task id %q", id)
}
return n, nil
}
func (r *taskLLMRuntime) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
ctx = llmrec.WithTaskID(ctx, r.taskID)
hooks := taskLLMStreamHooks{
current: func() (taskLLMSelection, error) {
task, profileID, revision, provider, err := r.current()
return taskLLMSelection{task: task, profileID: profileID, revision: revision, provider: provider}, err
},
exhaust: func(selection taskLLMSelection, cause error) (db.TaskLLMTransition, error) {
taskNum, _ := parseTaskID(r.taskID)
transition, err := r.s.m.pg.MarkTaskLLMProfileQuotaExhaustedAtRevision(taskNum, selection.profileID, selection.revision, cause.Error())
if err != nil {
return transition, err
}
if pt, getErr := r.s.m.pg.GetTask(taskNum); getErr == nil && pt != nil {
r.s.syncTaskLLMState(pt)
}
return transition, nil
},
transition: func(selection taskLLMSelection, transition db.TaskLLMTransition, cause error) {
r.s.emitTaskLLMTransition(selection.task, transition, cause)
},
}
return streamTaskLLM(ctx, r.taskID, req, hooks)
}
func streamTaskLLM(ctx context.Context, taskID string, req llm.CompletionRequest, hooks taskLLMStreamHooks) iter.Seq2[llm.StreamEvent, error] {
return func(yield func(llm.StreamEvent, error) bool) {
for {
selection, err := hooks.current()
if err != nil {
yield(llm.StreamEvent{}, err)
return
}
committed := false
var pending []llm.StreamEvent
var streamErr error
for event, err := range selection.provider.Stream(ctx, req) {
if err != nil {
streamErr = err
break
}
if !committed && !streamEventCommitsOutput(event) {
pending = append(pending, event)
continue
}
if !committed {
for _, buffered := range pending {
if !yield(buffered, nil) {
return
}
}
pending = nil
committed = true
}
if !yield(event, nil) {
return
}
}
if streamErr == nil {
for _, buffered := range pending {
if !yield(buffered, nil) {
return
}
}
return
}
// profileID=0 means the explicit chain was cleared while this stable task
// bundle was still in use. Agent/global fallback errors follow the legacy
// behavior and never mutate task failover state.
if selection.profileID == 0 || !isQuotaExhaustedError(streamErr) {
for _, buffered := range pending {
if !yield(buffered, nil) {
return
}
}
yield(llm.StreamEvent{}, streamErr)
return
}
transition, markErr := hooks.exhaust(selection, streamErr)
if markErr != nil {
cause := fmt.Errorf("mark profile quota exhausted after %v: %w", streamErr, markErr)
if committed {
// Output may already have driven tool execution. Report the persistence
// failure, but classify it as router-handled so the worker does not
// replay the entire intent and duplicate those side effects.
yield(llm.StreamEvent{}, &taskLLMError{taskID: taskID, cause: cause})
} else {
yield(llm.StreamEvent{}, cause)
}
return
}
if transition.Advanced && !transition.Stale && hooks.transition != nil {
hooks.transition(selection, transition, streamErr)
}
if committed || (!transition.Stale && transition.NextProfileID == nil) {
yield(llm.StreamEvent{}, &taskLLMError{taskID: taskID, chainExhausted: transition.ChainExhausted, cause: streamErr})
return
}
// No event reached the caller, so replaying the same logical request on
// the next profile cannot duplicate model output or tool execution.
}
}
}
func streamEventCommitsOutput(event llm.StreamEvent) bool {
switch event.Type {
case llm.SETextDelta, llm.SEThinkingDelta, llm.SEToolInputJSON:
return event.Text != ""
case llm.SEToolUseStart, llm.SEMessageDelta, llm.SEMessageStop:
return true
default:
return false
}
}
// 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()
}
chain, err := r.s.m.pg.TaskLLMProfiles(taskNum)
if err != nil {
return (agent.Config{}).CompactionWindow()
}
if len(chain) == 0 {
if _, cfg, ok := r.s.globalProvider(); ok {
return cfg.CompactionWindow()
}
return (agent.Config{}).CompactionWindow()
}
minimum := 0
for _, entry := range chain {
if cfg, ok := r.s.loadProfileConfig(entry.ProfileID); ok {
window := cfg.CompactionWindow()
if minimum == 0 || window < minimum {
minimum = window
}
}
}
if minimum == 0 {
return (agent.Config{}).CompactionWindow()
}
return minimum
}
// 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 {
return nil, agent.Config{}, false
}
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 || len(agentKeys) == 0 {
return false
}
state := t.llmStateSnapshot()
unboundReady := false
if len(state.ProfileIDs) > 0 {
if state.ActiveID != nil {
_, _, unboundReady = s.providerForProfile(*state.ActiveID)
}
} else {
_, _, unboundReady = s.globalProvider()
}
for _, key := range agentKeys {
if _, _, ok := s.agentBindingProvider(key); ok {
continue
}
if !unboundReady {
return false
}
}
return true
}
func (s *Server) invalidateTaskAgents() {
s.taskAgentMu.Lock()
s.taskAgents = map[string]*taskAgentBundle{}
s.taskAgentMu.Unlock()
}
func (s *Server) agentsForTask(t *Task) *taskAgentBundle {
s.taskAgentMu.Lock()
defer s.taskAgentMu.Unlock()
if bundle := s.taskAgents[t.ID]; bundle != nil {
return bundle
}
goalRuntime := &taskLLMRuntime{s: s, taskID: t.ID, agentKey: "goals"}
plannerRuntime := &taskLLMRuntime{s: s, taskID: t.ID, agentKey: "planner"}
workerRuntime := &taskLLMRuntime{s: s, taskID: t.ID, agentKey: "worker"}
mainRuntime := &taskLLMRuntime{s: s, taskID: t.ID, agentKey: "mainagent"}
tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts"))
window := workerRuntime.CompactionWindow()
wk := agent.NewWorker(workerRuntime, "task-router", s.m.dir, tx, window, s.agentMaxTurns("worker"))
wk.SetCompactionWindowResolver(workerRuntime.CompactionWindow)
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(plannerRuntime, "task-router", s.m.dir, tx, plannerRuntime.CompactionWindow(), s.agentMaxTurns("planner"))
pl.SetCompactionWindowResolver(plannerRuntime.CompactionWindow)
pl.SetKillWork(s.engine.KillWork)
pl.SetSteerWork(s.engine.SteerWork)
pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
pl.SetWebSearch(s.webSearchFor("planner"))
main := agent.NewMainAgent(mainRuntime, "task-router", s.m.dir, tx, mainRuntime.CompactionWindow(), s.agentMaxTurns("mainagent"))
main.SetCompactionWindowResolver(mainRuntime.CompactionWindow)
main.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
main.SetWebSearch(s.webSearchFor("mainagent"))
bundle := &taskAgentBundle{
runtime: goalRuntime, plannerRuntime: plannerRuntime, workerRuntime: workerRuntime,
mainRuntime: mainRuntime, pl: pl, wk: wk, main: main,
}
s.taskAgents[t.ID] = bundle
return bundle
}
func (s *Server) syncTaskLLMState(pt *db.Task) {
if pt == nil {
return
}
id := fmt.Sprintf("%d", pt.ID)
s.m.mu.Lock()
if task := s.m.tasks[id]; task != nil {
task.setLLMState(pt.LLMProfileID, pt.ActiveLLMProfileID, pt.LLMProfileIDs, pt.LLMChainRevision, pt.LLMFailoverState, pt.LLMFailoverReason)
}
s.m.mu.Unlock()
}
func (s *Server) emitTaskLLMTransition(t *Task, transition db.TaskLLMTransition, cause error) {
if t == nil {
return
}
previous := s.llmAuditProfile(transition.PreviousProfileID)
var next *llmAuditProfile
if transition.NextProfileID != nil {
next = s.llmAuditProfile(*transition.NextProfileID)
}
mode := "automatic"
kind := "llm_switch"
summary := fmt.Sprintf("%s 额度不足", llmAuditProfileLabel(previous))
if transition.NextProfileID != nil {
summary += fmt.Sprintf(",后续调用切换到 %s", llmAuditProfileLabel(next))
} else {
mode = "exhausted"
kind = "llm_failover"
summary += ",配置链已耗尽"
}
metadata, _ := json.Marshal(llmActivityMetadata{LLMTransition: llmTransitionAudit{
Mode: mode, Reason: cause.Error(), Previous: previous, Next: next,
}})
s.engine.emitActivity(t, db.Activity{Worker: "system", Kind: kind, IsError: transition.ChainExhausted, Summary: summary, Detail: cause.Error(), Metadata: metadata})
log.Printf("[llm-failover] task %s: %s", t.ID, summary)
}
func (s *Server) llmAuditProfile(id int64) *llmAuditProfile {
if id <= 0 || s.m == nil || s.m.pg == nil {
return nil
}
p, err := s.m.pg.ProfileByID(id)
if err != nil || p == nil {
return &llmAuditProfile{ID: id, Name: fmt.Sprintf("配置 #%d", id)}
}
return &llmAuditProfile{ID: p.ID, Name: p.Name, Format: p.Format, Model: p.Model}
}
func llmAuditProfileLabel(profile *llmAuditProfile) string {
if profile == nil {
return "默认配置"
}
name := profile.Name
if name == "" {
name = fmt.Sprintf("配置 #%d", profile.ID)
}
detail := []string{}
if profile.Format != "" {
detail = append(detail, profile.Format)
}
if profile.Model != "" {
detail = append(detail, profile.Model)
}
if len(detail) == 0 {
return name
}
return fmt.Sprintf("%s(%s)", name, strings.Join(detail, " / "))
}
func sameOptionalID(a, b *int64) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func (s *Server) emitManualTaskLLMSwitch(t *Task, previousID, nextID *int64) db.Activity {
previous := (*llmAuditProfile)(nil)
next := (*llmAuditProfile)(nil)
if previousID != nil {
previous = s.llmAuditProfile(*previousID)
}
if nextID != nil {
next = s.llmAuditProfile(*nextID)
}
summary := fmt.Sprintf("用户手动将任务 LLM 从 %s 切换到 %s", llmAuditProfileLabel(previous), llmAuditProfileLabel(next))
metadata, _ := json.Marshal(llmActivityMetadata{LLMTransition: llmTransitionAudit{
Mode: "manual", Reason: "用户手动切换任务 LLM", Previous: previous, Next: next,
}})
return s.engine.emitActivity(t, db.Activity{Worker: "system", Kind: "llm_switch", Summary: summary, Detail: summary, Metadata: metadata})
}