Files
ARTEX/server/goals.go
T
Autumn-27 c84bba477f 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 编辑器「配置与提示词」页新增「默认模型」下拉
2026-08-11 12:15:38 -04:00

80 lines
2.8 KiB
Go

package server
import (
"context"
"log"
"strconv"
"strings"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
)
type goalSpec struct {
Text string
VulnClass string
}
// createGoals materializes the goal node(s) under the task root (rel objective).
// Decomposition is done ENTIRELY by the LLM (the project requires an LLM). There is
// no rule-based fallback splitter — it only ever produced garbage (shredded URLs,
// meaningless 2-way splits). If the LLM yields nothing (an error), the raw task goal
// is used verbatim as a single goal so the task still has something to judge against.
// Returns the seeded specs so callers can emit activity records for them.
// emit, when non-nil, is forwarded to DecomposeGoals so LLM steps are visible in the UI.
func (s *Server) createGoals(ctx context.Context, t *Task, emit func(db.Activity)) []goalSpec {
// Use the SAME LLM the task runs on (its pinned profile, else the active profile),
// NOT agent.FromEnv() — the LLM is configured via the UI (DB profile), not env vars,
// so FromEnv returned empty and every task silently fell back to the crude rule
// splitter (which shredded URLs / made meaningless 2-way splits).
var specs []goalSpec
if cfg, ok := s.goalsLLMConfig(t); ok {
var as *db.AssetStore
if s.m != nil {
as = s.m.Assets()
}
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
for _, g := range agent.DecomposeGoals(ctx, cfg, t.Goal, t.Description, as, taskID, emit) {
if strings.TrimSpace(g.Text) != "" {
specs = append(specs, goalSpec{Text: g.Text, VulnClass: g.VulnClass})
}
}
}
if len(specs) == 0 {
// No rule-based splitting: use the raw task goal verbatim as a single goal
// (only reached on an LLM error — normal runs get real decomposed goals).
if g := strings.TrimSpace(t.Goal); g != "" {
log.Printf("[goals] task %s: LLM 目标拆解无产出,回退为「原始目标作为单目标」", t.ID)
specs = []goalSpec{{Text: g}}
}
}
origin, _ := t.Store.OriginFactID()
for _, g := range specs {
payload := map[string]any{"text": g.Text}
if g.VulnClass != "" {
payload["vulnclass"] = g.VulnClass
}
id, _ := t.Store.AddNode(db.KindGoal, payload, 0, "open", "system", nil)
if origin > 0 && id > 0 {
_ = t.Store.Link(origin, db.RelSpawns, id) // goals descend from the task root (origin fact)
}
}
return specs
}
// 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) {
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
}
}
return s.loadLLMConfig()
}