diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e611bd..26425de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,17 @@ # Changelog +## [Unreleased] - 2026-11-03 + +- Task thinking effort is selectable in task creation, the iMessage `create_task` + tool, and `spawn_task`. `auto` uses each model's provider default; `max` keeps + each selected profile's configured effort. Existing tasks keep their profile + settings. +- iMessage bridge gains `rename_task` (via `PATCH /api/tasks/{id}`), and + `create_task` now spells out that `name` is the display title and `description` + is long-form context, so names stop landing in descriptions. +- Asset graph: legend and labels are in English, and colors follow the app theme + instead of staying light inside dark mode. +- LLM Token Usage panel defaults to the New view; Legacy remains selectable. + ## [Unreleased] - 2026-10-31 diff --git a/agent/blackboard_inheritance_test.go b/agent/blackboard_inheritance_test.go index e2b8096..29d8bb3 100644 --- a/agent/blackboard_inheritance_test.go +++ b/agent/blackboard_inheritance_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "pta/db" actool "normasdk/tool" + "pta/db" ) func callReadJSON(t *testing.T, tool actool.CoreTool, input string) any { @@ -25,6 +25,58 @@ func callReadJSON(t *testing.T, tool actool.CoreTool, input string) any { return out } +func TestGraphOverviewKeepsCreationTargetAfterRename(t *testing.T) { + d := testDB(t) + defer d.Close() + + const originalName = "10.0.0.1:8080 audit" + task, err := d.CreateTaskWithOptions("", "Scan target", db.TaskCreateOptions{Name: originalName}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(task.ID) }) + + renamed := "other.com audit" + if _, err := d.UpdateTask(task.ID, db.TaskPatch{Name: &renamed}); err != nil { + t.Fatal(err) + } + + store := d.Exploration(task.ExplorationID) + origin, err := store.OriginFactID() + if err != nil { + t.Fatal(err) + } + node, err := store.GetNode(origin) + if err != nil || node == nil { + t.Fatalf("origin node: %v", err) + } + var payload struct { + Summary string `json:"summary"` + } + if err := json.Unmarshal(node.Payload, &payload); err != nil { + t.Fatal(err) + } + if !strings.Contains(payload.Summary, originalName) || strings.Contains(payload.Summary, renamed) { + t.Fatalf("origin summary lost creation target: %q", payload.Summary) + } + + tools := NewToolSet(store, "planner") + overview, ok := callReadJSON(t, tools.GraphOverviewTool(), "{}").(map[string]any) + if !ok { + t.Fatal("graph overview is not an object") + } + context, ok := overview["task"].(map[string]any) + if !ok { + t.Fatal("task context missing") + } + if context["creation_name"] != originalName || context["goal"] != "Scan target" { + t.Fatalf("wrong task context: %#v", context) + } + if _, hasDescription := context["description"]; hasDescription { + t.Fatalf("blank description included in task context: %#v", context) + } +} + func TestGraphOverviewExpandsAssociatedCompanyScope(t *testing.T) { d := testDB(t) defer d.Close() diff --git a/agent/tools.go b/agent/tools.go index f6eb63b..a840365 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -7,9 +7,9 @@ import ( "strconv" "strings" - "pta/db" acperm "normasdk/permission" actool "normasdk/tool" + "pta/db" ) // compactIntents distills intents to {id, summary, state, asset_ids, parents, @@ -510,7 +510,21 @@ func (t *ToolSet) graphOverviewData() map[string]any { // the original task (root) so the planner always has it, not just the // decomposed goals. if description, goal, err := t.ts.Root(); err == nil { - out["task"] = map[string]any{"description": description, "goal": goal} + task := map[string]any{"goal": goal} + if origin, err := t.ts.OriginFactID(); err == nil && origin > 0 { + if node, err := t.ts.GetNode(origin); err == nil && node != nil { + var payload struct { + CreationName string `json:"creation_name"` + } + if json.Unmarshal(node.Payload, &payload) == nil && payload.CreationName != "" { + task["creation_name"] = payload.CreationName + } + } + } + if description != "" { + task["description"] = description + } + out["task"] = task } // Direct source tasks are a live, read-only blackboard view. Keep their // summaries in a separate field so their intents never enter this task's diff --git a/bridge/index.ts b/bridge/index.ts index 8222ff1..685e6bc 100644 --- a/bridge/index.ts +++ b/bridge/index.ts @@ -181,26 +181,29 @@ const TOOLS: Record = { }, }, create_task: { - description: "Create a new jcyber task targeting a domain/host. Each target gets its own task. Include a clear goal and optional LLM profile id (1=DeepSeek Max, 5-10=Mercury 3 high). Returns the new task id.", + description: "Create a new jcyber task targeting a domain/host. Each target gets its own task. Optional name is the short display title; description is separate long-form context, never the task name. Include the target in name or goal. Set thinking_effort to auto for easy jobs or max to keep the selected profile's configured effort for hard ones. Returns the new task id.", parameters: { type: "object", properties: { - name: { type: "string", description: "Short task name, e.g. 'example.com scan'" }, + name: { type: "string", description: "Optional task display name/title, e.g. 'example.com scan'. Put the task name here, not in description." }, goal: { type: "string", description: "What to do against the target" }, - description: { type: "string", description: "Task description (defaults to Untitled Task)" }, - llm_profile_id: { type: "number", description: "Optional LLM profile id (1=DeepSeek Max, 5-10=Mercury 3)" }, + description: { type: "string", description: "Optional long-form task context/instructions, separate from the display name." }, + llm_profile_id: { type: "number", description: "Optional explicit LLM profile id." }, + thinking_effort: { type: "string", enum: ["max", "auto"], description: "Reasoning effort for this task: omit or 'max' to use the selected profile's configured effort, 'auto' to use the provider default." }, }, required: ["goal"], }, run: async (a) => { + const name = str(a.name).trim(); const res = await fetch(`${JCYBER_URL}/api/tasks`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${await auth()}` }, body: JSON.stringify({ - name: str(a.name), + name, goal: str(a.goal), - description: str(a.description) || "Untitled Task", + description: str(a.description), llm_profile_id: Number(a.llm_profile_id) > 0 ? Number(a.llm_profile_id) : undefined, + thinking_effort: a.thinking_effort, seed_first_intent: true, }), signal: AbortSignal.timeout(15_000), @@ -210,6 +213,28 @@ const TOOLS: Record = { return JSON.stringify({ id: t.id, name: str(t.name), status: str(t.status) }); }, }, + rename_task: { + description: "Rename an existing task's display name/title by id without changing its description, goal, or execution state.", + parameters: { + type: "object", + properties: { + id: { type: "number", description: "Task id" }, + name: { type: "string", description: "New non-empty task display name, at most 200 characters. Surrounding whitespace is trimmed." }, + }, + required: ["id", "name"], + }, + run: async (a) => { + const res = await fetch(`${JCYBER_URL}/api/tasks/${Number(a.id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${await auth()}` }, + body: JSON.stringify({ name: str(a.name) }), + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`rename task ${res.status}: ${(await res.text()).slice(0, 200)}`); + const t = record(await res.json()); + return JSON.stringify({ id: t.id, name: str(t.name) }); + }, + }, task_detail: { description: "Get details for one specific jcyber task by id (status, pause state, findings).", parameters: { type: "object", properties: { id: { type: "number", description: "Task id" } }, required: ["id"] }, @@ -270,9 +295,9 @@ const toolSpecs = (): ChatCompletionTool[] => async function subagent(prompt: string): Promise { const messages: ChatCompletionMessageParam[] = [ { role: "system", content: `You are a jcyber operations subagent. Fulfill this request using your tools. -Prefer direct tools for everything that has one: create_task for new targets, task_detail/task_findings for reads, -task_control to pause/resume, list_tasks for overview. Use jcyber_chat only for things without a direct tool -(hints, worker messages, platform reasoning). +Prefer direct tools for everything that has one: create_task for new targets, rename_task to change task names, +task_detail/task_findings for reads, task_control to pause/resume, list_tasks for overview. +Use jcyber_chat only for things without a direct tool (hints, worker messages, platform reasoning). When multiple targets are requested, create one task per target so they run in parallel. Return a concise factual summary of what happened, with exact task ids, names and statuses, in plain language. The front desk will text it to the user.` }, diff --git a/db/schema.sql b/db/schema.sql index 7298b14..7ab84e3 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -557,6 +557,7 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_ca ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_chain_revision BIGINT NOT NULL DEFAULT 0; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_effort TEXT NOT NULL DEFAULT '' CHECK (thinking_effort IN ('', 'auto', 'max')); ALTER TABLE tasks ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ; CREATE INDEX IF NOT EXISTS idx_tasks_category ON tasks(category_id, created_at DESC) WHERE deleted_at IS NULL AND category_id IS NOT NULL; diff --git a/db/task_archives_restore.go b/db/task_archives_restore.go index d57f4a5..28c6efa 100644 --- a/db/task_archives_restore.go +++ b/db/task_archives_restore.go @@ -120,7 +120,7 @@ AND NOT EXISTS ( } if _, err := tx.Exec(`UPDATE tasks SET name='',category_id=NULL,description='',goal='',paused=true,queued=false,queued_at=NULL,queue_mode='', - llm_profile_id=NULL,active_llm_profile_id=NULL,llm_chain_revision=llm_chain_revision+1, + llm_profile_id=NULL,active_llm_profile_id=NULL,thinking_effort='',llm_chain_revision=llm_chain_revision+1, company_id=NULL,parent_ref=NULL,timeout_seconds=0,coverage_enabled=true,pinned_at=NULL, first_run_at=NULL,deadline_at=NULL,archived_at=now(),deleted_at=now() WHERE id=$1`, taskID); err != nil { @@ -319,6 +319,7 @@ func restoreTaskStub(tx *sql.Tx, row map[string]any, taskID, remaining int64) er name=archived.name,category_id=archived.category_id,description=archived.description,goal=archived.goal, status=archived.status,paused=archived.paused,queued=false,queued_at=NULL,queue_mode='', llm_profile_id=archived.llm_profile_id,active_llm_profile_id=archived.active_llm_profile_id, + thinking_effort=COALESCE(archived.thinking_effort,''), llm_chain_revision=archived.llm_chain_revision,company_id=archived.company_id,parent_ref=archived.parent_ref, timeout_seconds=archived.timeout_seconds,plan_heartbeat_seconds=archived.plan_heartbeat_seconds, coverage_enabled=archived.coverage_enabled,pinned_at=archived.pinned_at,first_run_at=archived.first_run_at, diff --git a/db/task_archives_test.go b/db/task_archives_test.go index 7f42bfb..8f7f392 100644 --- a/db/task_archives_test.go +++ b/db/task_archives_test.go @@ -132,7 +132,7 @@ func TestTaskArchiveDatabaseRoundTrip(t *testing.T) { if err := d.EnsureLLMUsageTable(); err != nil { t.Fatal(err) } - task, err := d.CreateTaskWithOptions("archive database roundtrip", "restore exact graph", TaskCreateOptions{Name: "cold task"}) + task, err := d.CreateTaskWithOptions("archive database roundtrip", "restore exact graph", TaskCreateOptions{Name: "cold task", ThinkingEffort: "auto"}) if err != nil { t.Fatal(err) } @@ -282,7 +282,7 @@ VALUES($1,$2,0,'quota_exhausted','balance exhausted',$3,$4,$3)`, task.ID, llmPro if err != nil || live == nil { t.Fatalf("restored task = %+v, %v", live, err) } - if live.Name != "cold task" || !live.Paused { + if live.Name != "cold task" || !live.Paused || live.ThinkingEffort != "auto" { t.Fatalf("restored task metadata mismatch: %+v", live) } if err := d.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, task.ExplorationID).Scan(&nodes); err != nil { diff --git a/db/tasks.go b/db/tasks.go index c7bafbb..b67ac4d 100644 --- a/db/tasks.go +++ b/db/tasks.go @@ -30,6 +30,7 @@ type Task struct { // for ActiveLLMProfileID while older API clients still send one profile id. LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` ActiveLLMProfileID *int64 `json:"active_llm_profile_id,omitempty"` + ThinkingEffort string `json:"thinking_effort,omitempty"` LLMChainRevision int64 `json:"-"` LLMFailoverState string `json:"llm_failover_state,omitempty"` LLMFailoverReason string `json:"llm_failover_reason,omitempty"` @@ -149,6 +150,7 @@ type TaskCreateOptions struct { SourceTaskIDs []int64 CompanyIDs []int64 LLMProfileIDs []int64 + ThinkingEffort string TimeoutSeconds int PlanHeartbeatSeconds int // CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建 @@ -179,14 +181,23 @@ func (d *DB) CreateTaskWithOptions(description, goal string, opts TaskCreateOpti return nil, err } // origin fact: the exploration graph's root, a KindFact node (state='origin') - // holding the original task description. Goals/intents/findings descend from + // holding the creation name, description, and goal. Goals/intents/findings descend from // it, and the seeded target asset is anchored to it as lineage (the asset graph // is global and shared, not isolated per task). Being a fact (not a special 'begin' kind) lets every // intent uniformly connect to a fact node, including the first ones. + summary := "Task starting point" + if opts.Name != "" { + summary += ": " + opts.Name + } + if description != "" { + summary += "; description: " + description + } + summary += "; goal: " + goal originPayload, _ := json.Marshal(map[string]any{ - "summary": "Task starting point: " + description + "; goal: " + goal, - "description": description, - "goal": goal, + "summary": summary, + "creation_name": opts.Name, + "description": description, + "goal": goal, }) if _, err := tx.Exec(` INSERT INTO exploration_nodes(exploration_id, kind, payload, priority, state, origin) @@ -219,6 +230,7 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload)); Name: opts.Name, CategoryID: opts.CategoryID, CategoryName: categoryName, Description: description, Goal: goal, ExplorationID: expID, LLMProfileID: active, ActiveLLMProfileID: active, + ThinkingEffort: opts.ThinkingEffort, LLMProfileIDs: append([]int64(nil), opts.LLMProfileIDs...), SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...), CompanyIDs: append([]int64(nil), opts.CompanyIDs...), @@ -226,9 +238,9 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload)); CoverageEnabled: coverageEnabled, } if err := tx.QueryRow(` -INSERT INTO tasks(name, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled) -VALUES ($1,$2,$3,$4,$5,$6,$6,$7,$8,$9) -RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil { +INSERT INTO tasks(name, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled, thinking_effort) +VALUES ($1,$2,$3,$4,$5,$6,$6,$7,$8,$9,$10) +RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled, opts.ThinkingEffort).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil { return nil, err } if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil { @@ -344,11 +356,11 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error { const taskCols = `id, COALESCE(name,''), category_id, COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''), -description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at` +description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(thinking_effort,''), COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at` func scanTask(sc interface{ Scan(...any) error }) (*Task, error) { var t Task - if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil { + if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ThinkingEffort, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil { return nil, err } t.Pinned = t.PinnedAt != nil diff --git a/server/applied_rebuild_test.go b/server/applied_rebuild_test.go new file mode 100644 index 0000000..a4e60fa --- /dev/null +++ b/server/applied_rebuild_test.go @@ -0,0 +1,234 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "normasdk/llm" + "pta/db" + "pta/llmpool" +) + +func saveAppliedRebuildProfile(t *testing.T, s *Server, profile map[string]any) { + t.Helper() + body, err := json.Marshal(profile) + if err != nil { + t.Fatal(err) + } + response := httptest.NewRecorder() + s.pgSaveProfile(response, httptest.NewRequest(http.MethodPost, "/api/llm/profiles", bytes.NewReader(body))) + if response.Code != http.StatusOK { + t.Fatalf("save profile: %d %s", response.Code, response.Body.String()) + } +} + +func TestFallbackEditRebuildsAppliedPoolAfterInvalidDraft(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + activeServer, _ := effortWireServer(t) + defer activeServer.Close() + oldFallbackServer, oldRequests := effortWireServer(t) + defer oldFallbackServer.Close() + newFallbackServer, newRequests := effortWireServer(t) + defer newFallbackServer.Close() + previous, err := m.pg.ActiveProfile() + if err != nil { + t.Fatal(err) + } + stamp := time.Now().UnixNano() + active := &db.LLMProfile{ + Name: fmt.Sprintf("applied-head-%d", stamp), Format: "openai-responses", + BaseURL: activeServer.URL, Model: "applied-head-model", APIKey: "test-key", Streaming: true, + } + activeID, err := m.pg.SaveProfile(active) + if err != nil { + t.Fatal(err) + } + fallback := &db.LLMProfile{ + Name: fmt.Sprintf("fallback-edit-%d", stamp), Format: "openai-responses", + BaseURL: oldFallbackServer.URL, Model: "old-fallback-model", APIKey: "test-key", Streaming: true, Priority: 1000, + } + fallbackID, err := m.pg.SaveProfile(fallback) + if err != nil { + t.Fatal(err) + } + defer func() { + if previous != nil { + _ = m.pg.SetActiveProfile(previous.ID) + } else { + _, _ = m.pg.Exec(`UPDATE llm_profiles SET is_default=false WHERE id=$1`, activeID) + } + _ = m.pg.DeleteProfile(fallbackID) + _ = m.pg.DeleteProfile(activeID) + }() + if err := m.pg.SetActiveProfile(activeID); err != nil { + t.Fatal(err) + } + previousPool := m.LLMPoolEnabled() + if err := m.SetLLMPoolEnabled(true); err != nil { + t.Fatal(err) + } + defer func() { _ = m.SetLLMPoolEnabled(previousPool) }() + t.Setenv("JCYBER_LLM_ROUND_ROBIN", "") + s := &Server{m: m, llmHealth: newLLMHealthRegistry(m.pg)} + cfg, ok := s.loadLLMConfig() + if !ok { + t.Fatal("active profile unavailable") + } + if err := s.applyLLM(cfg); err != nil { + t.Fatal(err) + } + if _, ok := s.llmProv.(*llmpool.Pool); !ok { + t.Fatalf("initial failover pool unavailable: %T", s.llmProv) + } + saveAppliedRebuildProfile(t, s, map[string]any{ + "id": activeID, "name": "draft-" + active.Name, "format": active.Format, + "base_url": active.BaseURL, "model": "draft-head-model", "proxy": "://invalid", "streaming": true, + }) + saveAppliedRebuildProfile(t, s, map[string]any{ + "id": fallbackID, "name": fallback.Name, "format": fallback.Format, + "base_url": newFallbackServer.URL, "model": "new-fallback-model", "streaming": true, "priority": fallback.Priority, + }) + if s.llmProfileID != activeID || s.llmCfg.Model != active.Model { + t.Fatalf("global provider changed to draft: profile=%d model=%s", s.llmProfileID, s.llmCfg.Model) + } + pool, ok := s.llmProv.(*llmpool.Pool) + if !ok { + t.Fatalf("global failover pool unavailable after fallback edit: %T", s.llmProv) + } + var member *llmpool.Member + for _, candidate := range pool.Members() { + if candidate.ID == fallbackID { + member = candidate + break + } + } + if member == nil { + t.Fatal("edited fallback missing from global pool") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, _, _, _ = member.Prov.Complete(ctx, llm.CompletionRequest{}) + select { + case request := <-newRequests: + if request["model"] != "new-fallback-model" { + t.Fatalf("fallback request model = %v", request["model"]) + } + case request := <-oldRequests: + t.Fatalf("global pool used old fallback endpoint: %v", request) + case <-ctx.Done(): + t.Fatal("edited fallback sent no request") + } +} + +func TestRetryPolicyRefreshesAppliedProviderAfterInvalidDraft(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + var attempts atomic.Int32 + requests := make(chan map[string]any, 8) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + body, _ := io.ReadAll(r.Body) + var request map[string]any + if json.Unmarshal(body, &request) == nil { + requests <- request + } + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + previous, err := m.pg.ActiveProfile() + if err != nil { + t.Fatal(err) + } + previousPolicy := m.pg.LLMRetryPolicy() + defer func() { _ = m.pg.SetLLMRetryPolicy(previousPolicy) }() + profile := &db.LLMProfile{ + Name: fmt.Sprintf("applied-retry-%d", time.Now().UnixNano()), Format: "openai-responses", + BaseURL: srv.URL, Model: "applied-retry-model", APIKey: "test-key", Streaming: true, + Retry: db.RetryOverride{Empty: db.RetryRule{Attempts: 7}}, + } + id, err := m.pg.SaveProfile(profile) + if err != nil { + t.Fatal(err) + } + defer func() { + if previous != nil { + _ = m.pg.SetActiveProfile(previous.ID) + } else { + _, _ = m.pg.Exec(`UPDATE llm_profiles SET is_default=false WHERE id=$1`, id) + } + _ = m.pg.DeleteProfile(id) + }() + if err := m.pg.SetActiveProfile(id); err != nil { + t.Fatal(err) + } + previousPool := m.LLMPoolEnabled() + if err := m.SetLLMPoolEnabled(false); err != nil { + t.Fatal(err) + } + defer func() { _ = m.SetLLMPoolEnabled(previousPool) }() + s := &Server{m: m, llmHealth: newLLMHealthRegistry(m.pg)} + cfg, ok := s.loadLLMConfig() + if !ok { + t.Fatal("active profile unavailable") + } + if err := s.applyLLM(cfg); err != nil { + t.Fatal(err) + } + saveAppliedRebuildProfile(t, s, map[string]any{ + "id": id, "name": "draft-" + profile.Name, "format": profile.Format, + "base_url": profile.BaseURL, "model": "draft-retry-model", "proxy": "://invalid", "streaming": true, + "retry": map[string]any{"connect": map[string]any{"attempts": 4}, "empty": map[string]any{"attempts": 9}}, + }) + policy, err := json.Marshal(db.LLMRetryPolicy{ + Connect: db.RetryRule{Attempts: -1, IntervalMS: 1}, + Empty: db.RetryRule{Attempts: 2}, + }) + if err != nil { + t.Fatal(err) + } + response := httptest.NewRecorder() + s.pgSaveLLMRetryPolicy(response, httptest.NewRequest(http.MethodPost, "/api/llm/retry-policy", bytes.NewReader(policy))) + if response.Code != http.StatusOK { + t.Fatalf("save retry policy: %d %s", response.Code, response.Body.String()) + } + provider, applied, ok := s.globalProviderForEffort(ThinkingEffortMax) + if !ok || applied.Model != profile.Model || applied.Retry.ConnectAttempts != -1 || applied.Retry.EmptyAttempts != 7 { + t.Fatalf("global applied retry config = %+v, available = %v", applied, ok) + } + _, auto, ok := s.globalProviderForEffort(ThinkingEffortAuto) + if !ok || auto.Model != profile.Model || auto.Retry.ConnectAttempts != -1 || auto.Retry.EmptyAttempts != 7 { + t.Fatalf("auto applied retry config = %+v, available = %v", auto, ok) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, _, _, err = provider.Complete(ctx, llm.CompletionRequest{}) + if err == nil { + t.Fatal("provider unexpectedly accepted HTTP 503") + } + if got := attempts.Load(); got != 1 { + t.Fatalf("applied provider made %d requests with retries disabled; want 1", got) + } + select { + case request := <-requests: + if request["model"] != profile.Model { + t.Fatalf("applied request model = %v, want %s", request["model"], profile.Model) + } + default: + t.Fatal("applied provider sent no request") + } +} diff --git a/server/core_test.go b/server/core_test.go index e65f6eb..9e23c16 100644 --- a/server/core_test.go +++ b/server/core_test.go @@ -62,7 +62,7 @@ func TestCoreTaskLifecyclePG(t *testing.T) { } // create a task → 201, returns PG task (string id + exploration_id) - code, out := doRetry("POST", "/api/tasks", map[string]string{"description": "smoke", "goal": "测试 SQLi/XSS"}) + code, out := doRetry("POST", "/api/tasks", map[string]string{"name": "example.test smoke scan", "description": "smoke", "goal": "测试 SQLi/XSS"}) if code != 201 { t.Fatalf("create task: %d (%v)", code, out) } diff --git a/server/dto.go b/server/dto.go index 72b92f8..b494d19 100644 --- a/server/dto.go +++ b/server/dto.go @@ -55,6 +55,7 @@ type TaskDTO struct { LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // LLM profile used for this task; nil = default LLMProfileIDs []int64 `json:"llm_profile_ids"` ActiveLLMProfileID *int64 `json:"active_llm_profile_id,omitempty"` + ThinkingEffort string `json:"thinking_effort,omitempty"` LLMFailoverState string `json:"llm_failover_state"` LLMFailoverReason string `json:"llm_failover_reason,omitempty"` SourceTaskIDs []string `json:"source_task_ids"` @@ -129,6 +130,7 @@ func taskDTO(t *Task, status string) TaskDTO { LLMProfileID: llmState.ProfileID, LLMProfileIDs: profileIDs, ActiveLLMProfileID: llmState.ActiveID, + ThinkingEffort: t.ThinkingEffort, LLMFailoverState: llmState.FailoverState, LLMFailoverReason: llmState.FailoverReason, SourceTaskIDs: sourceIDs, diff --git a/server/effort_snapshot_test.go b/server/effort_snapshot_test.go new file mode 100644 index 0000000..4cdff6b --- /dev/null +++ b/server/effort_snapshot_test.go @@ -0,0 +1,87 @@ +package server + +import ( + "context" + "sync" + "testing" + "time" + + "normasdk/llm" + "pta/agent" +) + +type limiterWaitContext struct { + context.Context + entered chan struct{} + once sync.Once +} + +func (c *limiterWaitContext) Done() <-chan struct{} { + c.once.Do(func() { close(c.entered) }) + return c.Context.Done() +} + +func TestAutoOnlyAppliedProfileBuildsMaxEffort(t *testing.T) { + srv, requests := effortWireServer(t) + defer srv.Close() + base := agent.ConfigFrom("openai-responses", "applied-model", srv.URL, "test-key", "") + base.ThinkingType, base.ReasoningEffort = "enabled", "high" + s := &Server{m: &Manager{}} + auto, autoConfig, ok := s.providerForEffortUnlocked(42, ThinkingEffortAuto, &base, "applied") + if !ok || autoConfig.ThinkingType != "" || autoConfig.ReasoningEffort != "" { + t.Fatalf("auto provider config = %+v, available = %v", autoConfig, ok) + } + assertEffortWire(t, effortWireRequest(t, auto, requests), base.Model, ThinkingEffortAuto) + + max, maxConfig, ok := s.providerForProfileEffort(42, ThinkingEffortMax) + if !ok || maxConfig.Model != base.Model || maxConfig.ThinkingType != base.ThinkingType || maxConfig.ReasoningEffort != base.ReasoningEffort { + t.Fatalf("max provider config = %+v, available = %v; want applied profile", maxConfig, ok) + } + assertEffortWire(t, effortWireRequest(t, max, requests), base.Model, base.ReasoningEffort) +} + +func TestSelectedCallKeepsProviderAcrossSameRateEdit(t *testing.T) { + srv, requests := effortWireServer(t) + defer srv.Close() + oldConfig := agent.ConfigFrom("openai-responses", "old-model", srv.URL, "test-key", "") + oldConfig.ThinkingType, oldConfig.ReasoningEffort, oldConfig.RatePerSecond = "enabled", "high", 1 + s := &Server{m: &Manager{}} + oldProvider, _, ok := s.providerForEffortUnlocked(42, ThinkingEffortMax, &oldConfig, "applied") + if !ok { + t.Fatal("old provider unavailable") + } + assertEffortWire(t, effortWireRequest(t, oldProvider, requests), oldConfig.Model, oldConfig.ReasoningEffort) + + baseContext, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + waitContext := &limiterWaitContext{Context: baseContext, entered: make(chan struct{})} + finished := make(chan error, 1) + go func() { + _, _, _, err := oldProvider.Complete(waitContext, llm.CompletionRequest{}) + finished <- err + }() + select { + case <-waitContext.entered: + case <-baseContext.Done(): + t.Fatal("selected call did not enter shared rate limiter") + } + + newConfig := oldConfig + newConfig.Model, newConfig.ReasoningEffort = "new-model", "low" + newProvider, _, ok := s.providerForEffortUnlocked(42, ThinkingEffortMax, &newConfig, "applied") + if !ok { + t.Fatal("new provider unavailable") + } + select { + case <-finished: + case <-baseContext.Done(): + t.Fatal("selected call did not finish") + } + select { + case request := <-requests: + assertEffortWire(t, request, oldConfig.Model, oldConfig.ReasoningEffort) + case <-baseContext.Done(): + t.Fatal("selected call did not reach provider") + } + assertEffortWire(t, effortWireRequest(t, newProvider, requests), newConfig.Model, newConfig.ReasoningEffort) +} diff --git a/server/goals.go b/server/goals.go index 474f122..b5bda6c 100644 --- a/server/goals.go +++ b/server/goals.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/json" "fmt" "log" "sort" @@ -17,6 +18,27 @@ type goalSpec struct { VulnClass string } +func taskCreationName(t *Task) string { + if t.Store == nil { + return "" + } + origin, err := t.Store.OriginFactID() + if err != nil || origin == 0 { + return "" + } + node, err := t.Store.GetNode(origin) + if err != nil || node == nil { + return "" + } + var payload struct { + CreationName string `json:"creation_name"` + } + if json.Unmarshal(node.Payload, &payload) != nil { + return "" + } + return payload.CreationName +} + // launchTask runs the shared post-creation sequence for a task created via ANY // path (HTTP createTask 或 orchestration spawn_task),避免两处复制粘贴: // 1. seed 根资产,喂给事件驱动 loop; @@ -25,10 +47,14 @@ type goalSpec struct { // 分解完再 engine.Run —— 引擎在 goal 节点就绪后才启动,避免 planner 抢在 goal 之前跑的竞态。 // // 异步(goroutine)所以调用方立即返回,两条路径行为一致:秒建任务、后台拆目标。 -func (s *Server) launchTask(t *Task, seedText string, seedFirstIntent bool) { +func (s *Server) launchTask(t *Task, seedFirstIntent bool) { if !s.engine.beginTaskOperation(t.ID) { return } + seedText := taskCreationName(t) + if _, _, _, ok := parseTarget(seedText); !ok { + seedText = t.Goal + } s.seed(t, seedText) if seedFirstIntent { s.seedFirstIntent(t) @@ -384,7 +410,11 @@ func (s *Server) createGoals(ctx context.Context, t *Task, emit func(db.Activity taskID, _ := strconv.ParseInt(t.ID, 10, 64) s.engine.BeginLLMCall(t.ID) goalRuntime := s.agentsForTask(t).runtime - decomposed := agent.DecomposeGoalsWithProvider(ctx, goalRuntime, s.m.dir, t.Goal, t.Description, as, t.Store, taskID, goalRuntime.nonStreaming(), goalRuntime.maxTokens(), emit) + description := t.Description + if name := taskCreationName(t); name != "" { + description = "Target from task creation: " + name + "\n" + description + } + decomposed := agent.DecomposeGoalsWithProvider(ctx, goalRuntime, s.m.dir, t.Goal, description, as, t.Store, taskID, goalRuntime.nonStreaming(), goalRuntime.maxTokens(), emit) s.engine.EndLLMCall(t.ID) for _, g := range decomposed { if strings.TrimSpace(g.Text) != "" { diff --git a/server/llmpool.go b/server/llmpool.go index f4eb70c..5cb8c2e 100644 --- a/server/llmpool.go +++ b/server/llmpool.go @@ -3,6 +3,7 @@ package server import ( "log" "os" + "sort" "strings" "time" @@ -59,18 +60,44 @@ func newLLMHealthRegistry(pg *db.DB) *llmpool.Registry { // poolMember builds one chain member from a profile, reusing the per-profile // provider cache so every agent pointing at the same profile shares one provider // (hence one rate limiter). Returns nil when the profile can't be built. -func (s *Server) poolMember(p *db.LLMProfile, rank int) *llmpool.Member { - prov, cfg, ok := s.providerForProfile(p.ID) +func (s *Server) poolMember(p *db.LLMProfile, effort string) *llmpool.Member { + if !p.IsDefault { + excluded := p.PoolExclude + if cached := s.cachedProfileEntry(p.ID, effort); cached != nil { + excluded = cached.poolExclude + } + if excluded { + return nil + } + } + prov, cfg, ok := s.providerForProfileEffortUnlocked(p.ID, effort) if !ok { return nil } + name, model, format, priority := s.poolMetadata(p, cfg, effort) + rank := priority + if p.IsDefault { + rank = llmpool.RankActive - 1 + } return &llmpool.Member{ - ID: p.ID, Name: p.Name, Model: p.Model, Format: p.Format, - Priority: p.Priority, Active: p.IsDefault, Rank: rank, + ID: p.ID, Name: name, Model: model, Format: format, + Priority: priority, Active: p.IsDefault, Rank: rank, WindowTokens: cfg.CompactionWindow(), Prov: prov, } } +func (s *Server) poolMetadata(p *db.LLMProfile, cfg agent.Config, effort string) (string, string, string, int) { + name, model, format, priority := p.Name, p.Model, p.Format, p.Priority + cached := s.cachedProfileEntry(p.ID, effort) + if cached != nil && resolvedEffortConfig(cached.cfg, effort) == cfg { + if cached.name != "" { + name = cached.name + } + model, format, priority = cfg.Model, cfg.Provider(), cached.priority + } + return name, model, format, priority +} + // headRank returns the rank the chain head gets. Default RankActive, so the // globally active profile always leads. With JCYBER_LLM_ROUND_ROBIN set // (1/true/on) it returns the profile's own priority, letting equal-priority @@ -92,11 +119,11 @@ func headRank(priority int) int { // // Returns nil when failover is off or the chain has fewer than two usable members // — callers then use the bare provider, which is byte-for-byte the old behavior. -func (s *Server) poolChain(headID int64, headProv llm.Provider, headCfg agent.Config) *llmpool.Pool { +func (s *Server) poolChain(headID int64, headProv llm.Provider, headCfg agent.Config, effort string) *llmpool.Pool { if s.m == nil || s.m.pg == nil || !s.m.LLMPoolEnabled() { return nil } - profs, err := s.m.pg.PoolProfiles() + profs, err := s.m.pg.ListProfiles() if err != nil { log.Printf("[llmpool] Failed to read rotation chain: %v", err) return nil @@ -115,13 +142,14 @@ func (s *Server) poolChain(headID int64, headProv llm.Provider, headCfg agent.Co return nil } } + headName, headModel, headFormat, headPriority := s.poolMetadata(head, headCfg, effort) // Round-robin mode: the head keeps its configured priority instead of the // synthetic top rank, so profiles sharing a priority genuinely rotate per call // (llmpool rotates within each equal-rank group) instead of only failing over // on error. Unset keeps the old active-heads-the-chain failover behavior. members := []*llmpool.Member{{ - ID: head.ID, Name: head.Name, Model: head.Model, Format: head.Format, - Priority: head.Priority, Active: head.IsDefault, Rank: headRank(head.Priority), + ID: head.ID, Name: headName, Model: headModel, Format: headFormat, + Priority: headPriority, Active: head.IsDefault, Rank: headRank(headPriority), WindowTokens: headCfg.CompactionWindow(), Prov: headProv, }} for _, p := range profs { @@ -130,24 +158,26 @@ func (s *Server) poolChain(headID int64, headProv llm.Provider, headCfg agent.Co } // The globally active profile outranks the others when it isn't the head // (bound-fallback chains), so it stays the first fallback tried. - rank := p.Priority - if p.IsDefault { - rank = llmpool.RankActive - 1 - } - if m := s.poolMember(p, rank); m != nil { + if m := s.poolMember(p, effort); m != nil { members = append(members, m) } } if len(members) < 2 { return nil // nothing to fail over to } + fallbacks := members[1:] + sort.SliceStable(fallbacks, func(i, j int) bool { return fallbacks[i].Rank > fallbacks[j].Rank }) return llmpool.New(members, s.llmHealth) } // poolForActive wraps the globally-active provider in the failover chain. Returns // prov unchanged when failover is off or there's nothing to fall back to. func (s *Server) poolForActive(activeID int64, prov llm.Provider, cfg agent.Config) llm.Provider { - pool := s.poolChain(activeID, prov, cfg) + return s.poolForActiveEffort(activeID, prov, cfg, "") +} + +func (s *Server) poolForActiveEffort(activeID int64, prov llm.Provider, cfg agent.Config, effort string) llm.Provider { + pool := s.poolChain(activeID, prov, cfg, effort) if pool == nil { return prov } @@ -164,10 +194,14 @@ func (s *Server) poolForActive(activeID int64, prov llm.Provider, cfg agent.Conf // are on; otherwise a bound profile stays exclusive (fails hard), which is the // default and the documented precedence. func (s *Server) poolForBinding(id int64, prov llm.Provider, cfg agent.Config) llm.Provider { + return s.poolForBindingEffortLocked(id, prov, cfg, "") +} + +func (s *Server) poolForBindingEffortLocked(id int64, prov llm.Provider, cfg agent.Config, effort string) llm.Provider { if s.m == nil || !s.m.LLMPoolEnabled() || !s.m.LLMPoolBindFallback() { return prov } - if pool := s.poolChain(id, prov, cfg); pool != nil { + if pool := s.poolChain(id, prov, cfg, effort); pool != nil { return pool } return prov @@ -195,6 +229,8 @@ type LLMPoolMemberStatus struct { // on, the resolved chain order, and each profile's breaker state. Excluded // profiles are listed too (flagged), so the user can see why one isn't in line. func (s *Server) llmPoolStatus() map[string]any { + s.profileReapplyMu.RLock() + defer s.profileReapplyMu.RUnlock() out := map[string]any{ "enabled": false, "bind_fallback": false, @@ -223,6 +259,14 @@ func (s *Server) llmPoolStatus() map[string]any { State: "ok", Fails: st.Fails, Trips: st.Trips, LastError: st.LastError, } + cached := s.cachedProfileEntry(p.ID, "") + if cached != nil { + if cached.name != "" { + m.Name = cached.name + } + m.Model, m.Format = cached.cfg.Model, cached.cfg.Provider() + m.Priority, m.Excluded = cached.priority, cached.poolExclude + } if st.Open() { m.State = "tripped" m.CooldownSecs = int(st.OpenUntil.Sub(now).Seconds()) + 1 diff --git a/server/manager.go b/server/manager.go index 24c095e..71a7031 100644 --- a/server/manager.go +++ b/server/manager.go @@ -14,13 +14,13 @@ import ( "sync" "time" + actool "normasdk/tool" "pta/agent" pgdb "pta/db" "pta/enrich" "pta/guard" "pta/intercept" "pta/traffic" - actool "normasdk/tool" ) // Task is one engagement: a description + goal + its own exploration store, @@ -48,6 +48,7 @@ type Task struct { LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务 planner/worker 的 LLM 配置;nil=用全局激活配置 LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` ActiveLLMProfileID *int64 `json:"active_llm_profile_id,omitempty"` + ThinkingEffort string `json:"thinking_effort,omitempty"` LLMChainRevision int64 `json:"-"` LLMFailoverState string `json:"llm_failover_state,omitempty"` LLMFailoverReason string `json:"llm_failover_reason,omitempty"` @@ -839,6 +840,7 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter CompletedAt: unixOrZero(pt.CompletedAt), Status: pt.Status, ParentRef: pt.ParentRef, LLMProfileID: pt.LLMProfileID, LLMProfileIDs: append([]int64(nil), pt.LLMProfileIDs...), ActiveLLMProfileID: pt.ActiveLLMProfileID, + ThinkingEffort: pt.ThinkingEffort, LLMChainRevision: pt.LLMChainRevision, LLMFailoverState: pt.LLMFailoverState, LLMFailoverReason: pt.LLMFailoverReason, SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...), diff --git a/server/manager_delete_files_test.go b/server/manager_delete_files_test.go index 498e5a9..3b07c0e 100644 --- a/server/manager_delete_files_test.go +++ b/server/manager_delete_files_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "time" pgdb "pta/db" "pta/traffic" @@ -46,6 +47,69 @@ func TestSeedAssociatesTargetAssetWithTask(t *testing.T) { } } +func TestCreationTargetSurvivesTaskRename(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer m.Close() + host := fmt.Sprintf("seed-%d.example.test", time.Now().UnixNano()) + creationName := host + ":8080 audit" + task, err := m.CreateTaskWithOptions("Use https://docs.vendor.net as reference", "Find exposed admin routes", pgdb.TaskCreateOptions{Name: creationName}) + if err != nil { + t.Fatal(err) + } + defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{DeleteAssets: true}) }() + s := &Server{m: m} + name := "other.example.test" + if _, err := m.UpdateTaskMetadata(task.ID, pgdb.TaskPatch{Name: &name}); err != nil { + t.Fatal(err) + } + if got := taskCreationName(task); got != creationName { + t.Fatalf("creation name after rename = %q, want %q", got, creationName) + } + s.seed(task, taskCreationName(task)) + assets, err := m.Assets().QueryByTask(mustTaskID(t, task.ID), "", 20, 0) + if err != nil { + t.Fatal(err) + } + wantURL := "http://" + host + ":8080" + found := false + for _, asset := range assets { + if asset.URL == wantURL { + found = true + } + if asset.Domain == "docs.vendor.net" || strings.Contains(asset.URL, "docs.vendor.net") { + t.Fatalf("reference URL seeded as target: %+v", asset) + } + } + if !found { + t.Fatalf("seeded assets after rename = %+v, want %q", assets, wantURL) + } +} + +func TestUnparseableNameDoesNotSeedReferenceURL(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer m.Close() + task, err := m.CreateTaskWithOptions("Use https://docs.vendor.net as reference", "Find exposed admin routes", pgdb.TaskCreateOptions{Name: "internal-api scan"}) + if err != nil { + t.Fatal(err) + } + defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{DeleteAssets: true}) }() + s := &Server{m: m} + s.seed(task, taskCreationName(task)) + assets, err := m.Assets().QueryByTask(mustTaskID(t, task.ID), "", 20, 0) + if err != nil { + t.Fatal(err) + } + if len(assets) != 0 { + t.Fatalf("unparseable name seeded reference URL: %+v", assets) + } +} + func TestDeleteTaskFilesRemovesOnlyOwnedWorkspaceAndTranscripts(t *testing.T) { dataDir := t.TempDir() diff --git a/server/orchestration.go b/server/orchestration.go index a9f5eac..34d1ceb 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -9,10 +9,10 @@ import ( "strings" "time" - "pta/agent" - "pta/db" "normasdk/permission" actool "normasdk/tool" + "pta/agent" + "pta/db" ) // jsonResult marshals v to a JSON tool result. @@ -240,38 +240,47 @@ func (s *Server) toolListLLMProfiles() actool.CoreTool { func (s *Server) toolSpawnTask() actool.CoreTool { return wrTool("spawn_task", - "Create a subtask and start its exploration engine, returning task_id. Delegate a unit of work (such as a challenge or target) as an independent task. parent_ref is optional: supply the current orchestration's parent task id to establish a parent-child relationship.", + "Create a subtask and start its exploration engine, returning task_id. Delegate a unit of work (such as a challenge or target) as an independent task. Put an optional name or target separately from its description. parent_ref is optional: supply the current orchestration's parent task id to establish a parent-child relationship.", objSchema(map[string]any{ - "description": strParam("Task description (short title)"), + "name": strParam("Optional task name; include a domain or IP target here to seed the task's asset graph, or put the target in the goal"), + "description": strParam("Task description (execution context, separate from the name)"), "goal": strParam("Task goal (what to achieve)"), "parent_ref": strParam("Optional: parent task id (for a parent-child relationship)"), "source_task_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": fmt.Sprintf("Optional: list of source task ids for read-only inheritance (up to %d). The subtask can reference assets/conclusions already discovered by these tasks as a starting point; unlike parent_ref's parent-child pointer, this inherits content.", db.MaxTaskSourceCount)}, "llm_profile_id": map[string]any{"type": "integer", "description": "Optional: LLM profile id for this subtask's planner/worker (see list_llm_profiles); if omitted, inherit the parent task's profile, then fall back to the globally active profile"}, + "thinking_effort": map[string]any{"type": "string", "enum": []string{ThinkingEffortMax, ThinkingEffortAuto}, "description": "Optional: max keeps the selected profile's configured thinking effort; auto lets the provider choose its default. Defaults to max."}, "timeout_seconds": map[string]any{"type": "integer", "description": "Optional: task timeout (seconds). Triggers graceful shutdown and enters the timeout terminal state when reached; omitted or 0 = unlimited"}, "plan_heartbeat_seconds": map[string]any{"type": "integer", "description": "Optional: planner heartbeat interval (seconds). If this interval passes since the last planning round ended/task started with no trigger, start a planning round (deadlock safeguard and supervision of running workers). Omitted or 0 = default 600 (10min);"}, - "seed_first_intent": map[string]any{"type": "boolean", "description": "Optional: enable for simple tasks to issue a seed intent on creation (content = description + goal), allowing the worker to begin testing without waiting for the first planner round; default false (standard plan-then-execute flow)."}, + "seed_first_intent": map[string]any{"type": "boolean", "description": "Optional: enable for simple tasks to issue a seed intent on creation (content = name + description + goal), allowing the worker to begin testing without waiting for the first planner round; default false (standard plan-then-execute flow)."}, }, "description", "goal"), func(_ context.Context, in json.RawMessage) (actool.Result, error) { var a struct { + Name string `json:"name"` Description string `json:"description"` Goal string `json:"goal"` ParentRef string `json:"parent_ref"` SourceTaskIDs []string `json:"source_task_ids"` LLMProfileID json.RawMessage `json:"llm_profile_id"` + ThinkingEffort string `json:"thinking_effort"` TimeoutSeconds int `json:"timeout_seconds"` PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` SeedFirstIntent bool `json:"seed_first_intent"` } _ = json.Unmarshal(in, &a) - if strings.TrimSpace(a.Description) == "" { - a.Description = "Untitled Task" - } + a.Name = strings.TrimSpace(a.Name) if strings.TrimSpace(a.Goal) == "" { return actool.Errorf("goal is required"), nil } if a.TimeoutSeconds < 0 { a.TimeoutSeconds = 0 } + switch a.ThinkingEffort { + case "": + a.ThinkingEffort = ThinkingEffortMax + case ThinkingEffortMax, ThinkingEffortAuto: + default: + return actool.Errorf(`thinking_effort must be "auto" or "max"`), nil + } // 只读继承来源任务:数量上限 + 每个 id 有效/去重/存在,校验规则与 HTTP 建任务一致。 if len(a.SourceTaskIDs) > db.MaxTaskSourceCount { return actool.Errorf(fmt.Sprintf("Select at most %d linked tasks", db.MaxTaskSourceCount)), nil @@ -306,8 +315,10 @@ func (s *Server) toolSpawnTask() actool.CoreTool { llmIDs = []int64{*pin} } t, err := s.m.CreateTaskWithOptions(a.Description, a.Goal, db.TaskCreateOptions{ + Name: a.Name, SourceTaskIDs: sourceIDs, LLMProfileIDs: llmIDs, + ThinkingEffort: a.ThinkingEffort, TimeoutSeconds: a.TimeoutSeconds, PlanHeartbeatSeconds: a.PlanHeartbeatSeconds, }) @@ -323,7 +334,7 @@ func (s *Server) toolSpawnTask() actool.CoreTool { // 共享的建后流程,与 HTTP 建任务(server.go createTask)复用同一段 launchTask: // seed + 后台可见地做目标分解(第0轮/LLM步骤/逐条goal) + engine.Run。 // seed_first_intent 默认 false(标准先规划再执行);简单任务可开启直接下发一 work 测试。 - s.launchTask(t, a.Description+" "+a.Goal, a.SeedFirstIntent) + s.launchTask(t, a.SeedFirstIntent) return actool.Text(fmt.Sprintf("task created: %s", t.ID)), nil }) } @@ -487,6 +498,7 @@ func (s *Server) seedOrchestrationTools() { _ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, autoAgents) } s.refreshBuiltinToolSchemas() + s.refreshSpawnTaskSchema() s.seedAutoDefaultBindings() s.seedPlannerDefaultBindings() s.seedPlannerListAssetsBinding() @@ -512,7 +524,6 @@ func (s *Server) seedOrchestrationTools() { // orchestration + platform tools into already-seeded rows ONCE per version flag — // SeedTool is first-insert-only, so a new param (e.g. spawn_task 的 llm_profile) never // reaches an old DB otherwise. Preserves each tool's agent binding + enabled flag. -// Bump the flag whenever these tools' schemas/descriptions change in code. func (s *Server) refreshBuiltinToolSchemas() { const flag = "tool_schema_refresh_v7_list_facts_paging" if v, _, _ := s.m.pg.GetSetting(flag); v == "true" { @@ -520,6 +531,9 @@ func (s *Server) refreshBuiltinToolSchemas() { } tools := append(s.orchestrationTools(), s.platformTools()...) for _, t := range tools { + if t.Name() == "spawn_task" { + continue + } schema, _ := json.Marshal(t.InputSchema()) if err := s.m.pg.RefreshToolDefaults(t.Name(), t.Description(), schema); err != nil { log.Printf("[tools] refresh %s schema failed: %v", t.Name(), err) @@ -546,6 +560,33 @@ func (s *Server) refreshBuiltinToolSchemas() { log.Printf("[tools] Refreshed orchestration/platform tool schemas to code defaults (one-time)") } +func (s *Server) refreshSpawnTaskSchema() { + const flag = "tool_schema_refresh_v11_spawn_optional_name" + if v, _, _ := s.m.pg.GetSetting(flag); v == "true" { + return + } + tool := s.toolSpawnTask() + schema, err := json.Marshal(tool.InputSchema()) + if err != nil { + log.Printf("[tools] refresh spawn_task schema failed: %v", err) + return + } + var key string + if err := s.m.pg.QueryRow(`UPDATE tools SET schema=schema || jsonb_build_object( + 'properties', ($2::jsonb->'properties') || CASE + WHEN schema #>> '{properties,description,description}' = 'Task description (short title)' + THEN jsonb_set(schema->'properties', '{description,description}', $2::jsonb #> '{properties,description,description}') + ELSE COALESCE(schema->'properties','{}'::jsonb) END, + 'required', COALESCE(schema->'required','[]'::jsonb) - 'name' +), updated_at=now() WHERE key=$1 AND system RETURNING key`, tool.Name(), string(schema)).Scan(&key); err != nil { + log.Printf("[tools] refresh spawn_task schema failed: %v", err) + return + } + if err := s.m.pg.SetSetting(flag, "true"); err != nil { + log.Printf("[tools] record spawn_task schema refresh failed: %v", err) + } +} + // unbindGoalMetDefault removes goal_met's default "planner" binding ONCE (guarded by // a settings flag), so existing DBs match the new default of NO agent. goal_met bypasses // per-goal prove_goal to declare the whole task done — powerful/risky and redundant with diff --git a/server/orchestration_spawn_test.go b/server/orchestration_spawn_test.go new file mode 100644 index 0000000..3d79694 --- /dev/null +++ b/server/orchestration_spawn_test.go @@ -0,0 +1,212 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" +) + +func TestSpawnTaskPreservesNamedTarget(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer m.Close() + s := newAdmissionTestServer(m, func(*Task) bool { return false }) + host := fmt.Sprintf("spawn-%d.example.test", time.Now().UnixNano()) + name := host + ":8080 audit" + in, err := json.Marshal(map[string]string{ + "name": name, "description": "Use https://docs.vendor.net as reference", "goal": "Find exposed admin routes", + }) + if err != nil { + t.Fatal(err) + } + result, err := s.toolSpawnTask().Call(context.Background(), in, nil) + if err != nil || result.IsError { + t.Fatalf("spawn_task: err=%v result=%+v", err, result) + } + var spawned *Task + for _, task := range m.List() { + if task.Name == name { + spawned = task + break + } + } + if spawned == nil { + t.Fatalf("spawn_task did not create task named %q", name) + } + defer func() { _, _ = m.DeleteTask(spawned.ID, DeleteTaskOptions{DeleteAssets: true}) }() + if got := taskCreationName(spawned); got != name { + t.Fatalf("creation name = %q, want %q", got, name) + } + assets, err := m.Assets().QueryByTask(mustTaskID(t, spawned.ID), "", 20, 0) + if err != nil { + t.Fatal(err) + } + wantURL := "http://" + host + ":8080" + for _, asset := range assets { + if asset.URL == wantURL { + return + } + } + t.Fatalf("seeded assets = %+v, want %q", assets, wantURL) +} + +func TestSpawnTaskSeedsGoalTargetWithoutNameOrDescriptionReference(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + s := newAdmissionTestServer(m, func(*Task) bool { return false }) + host := fmt.Sprintf("goal-%d.example.test", time.Now().UnixNano()) + goal := "Map http://" + host + in, err := json.Marshal(map[string]string{ + "description": "Use https://docs.vendor.net as reference", "goal": goal, + }) + if err != nil { + t.Fatal(err) + } + result, err := s.toolSpawnTask().Call(context.Background(), in, nil) + if err != nil || result.IsError { + t.Fatalf("spawn_task: err=%v result=%+v", err, result) + } + var spawned *Task + for _, task := range m.List() { + if task.Goal == goal { + spawned = task + break + } + } + if spawned == nil { + t.Fatalf("spawn_task did not create task with goal %q", goal) + } + defer func() { _, _ = m.DeleteTask(spawned.ID, DeleteTaskOptions{DeleteAssets: true}) }() + if spawned.Name != "" || taskCreationName(spawned) != "" { + t.Fatalf("spawn_task assigned a name when omitted: %q", spawned.Name) + } + assets, err := m.Assets().QueryByTask(mustTaskID(t, spawned.ID), "", 20, 0) + if err != nil { + t.Fatal(err) + } + goalFound := false + for _, asset := range assets { + if asset.URL == "http://"+host { + goalFound = true + } + if asset.URL == "https://docs.vendor.net" { + t.Fatalf("description reference seeded as target: %+v", assets) + } + } + if !goalFound { + t.Fatalf("goal target missing from seeded assets: %+v", assets) + } +} + +func TestSpawnSchemaRefreshPreservesSavedEdits(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + t.Cleanup(func() { m.Close() }) + s := &Server{m: m} + tool := s.toolSpawnTask() + schema, err := json.Marshal(tool.InputSchema()) + if err != nil { + t.Fatal(err) + } + before, err := m.pg.GetTool(tool.Name()) + if err != nil { + t.Fatal(err) + } + existed := before != nil + if !existed { + if err := m.pg.SeedTool(tool.Name(), tool.Description(), schema, json.RawMessage(`[]`)); err != nil { + t.Fatal(err) + } + } else { + t.Cleanup(func() { + agents, _ := json.Marshal(before.Agents) + _ = m.pg.UpdateTool(tool.Name(), before.Description, before.Schema, agents, before.Enabled) + }) + } + if !existed { + t.Cleanup(func() { _, _ = m.pg.Exec(`DELETE FROM tools WHERE key=$1`, tool.Name()) }) + } + const flag = "tool_schema_refresh_v11_spawn_optional_name" + previous, present, err := m.pg.GetSetting(flag) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if present { + _ = m.pg.SetSetting(flag, previous) + } else { + _, _ = m.pg.Exec(`DELETE FROM settings WHERE key=$1`, flag) + } + }) + custom := json.RawMessage(`{"type":"object","properties":{"description":{"type":"string","description":"saved context","default":"keep me"},"goal":{"type":"string","description":"saved goal"},"timeout_seconds":{"type":"integer","default":23}},"required":["name","description","goal"]}`) + if err := m.pg.UpdateTool(tool.Name(), "saved spawn instructions", custom, json.RawMessage(`["worker"]`), false); err != nil { + t.Fatal(err) + } + if err := m.pg.SetSetting(flag, "false"); err != nil { + t.Fatal(err) + } + s.refreshSpawnTaskSchema() + got, err := m.pg.GetTool(tool.Name()) + if err != nil || got == nil { + t.Fatalf("spawn_task missing after refresh: %v", err) + } + if got.Description != "saved spawn instructions" || got.Enabled || len(got.Agents) != 1 || got.Agents[0] != "worker" { + t.Fatalf("saved tool settings changed: %+v", got) + } + type schemaState struct { + Properties map[string]map[string]any `json:"properties"` + Required []string `json:"required"` + } + var parsed schemaState + if err := json.Unmarshal(got.Schema, &parsed); err != nil { + t.Fatal(err) + } + if parsed.Properties["description"]["description"] != "saved context" || parsed.Properties["description"]["default"] != "keep me" || parsed.Properties["goal"]["description"] != "saved goal" || parsed.Properties["timeout_seconds"]["default"] != float64(23) { + t.Fatalf("saved parameter edits changed: %+v", parsed.Properties) + } + if parsed.Properties["name"]["type"] != "string" { + t.Fatalf("missing name parameter: %+v", parsed.Properties["name"]) + } + efforts, ok := parsed.Properties["thinking_effort"]["enum"].([]any) + if !ok || len(efforts) != 2 || efforts[0] != ThinkingEffortMax || efforts[1] != ThinkingEffortAuto { + t.Fatalf("missing thinking effort options: %v", parsed.Properties["thinking_effort"]) + } + names := 0 + for _, required := range parsed.Required { + if required == "name" { + names++ + } + } + if names != 0 { + t.Fatalf("required name count = %d, want 0", names) + } + legacy := json.RawMessage(`{"type":"object","properties":{"description":{"type":"string","description":"Task description (short title)","default":"keep me"},"goal":{"type":"string"}},"required":["description","goal"]}`) + if err := m.pg.UpdateTool(tool.Name(), "saved spawn instructions", legacy, json.RawMessage(`["worker"]`), false); err != nil { + t.Fatal(err) + } + if err := m.pg.SetSetting(flag, "false"); err != nil { + t.Fatal(err) + } + s.refreshSpawnTaskSchema() + got, err = m.pg.GetTool(tool.Name()) + if err != nil || got == nil { + t.Fatalf("spawn_task missing after legacy refresh: %v", err) + } + parsed = schemaState{} + if err := json.Unmarshal(got.Schema, &parsed); err != nil { + t.Fatal(err) + } + wantDescription := tool.InputSchema()["properties"].(map[string]any)["description"].(map[string]any)["description"] + if parsed.Properties["description"]["description"] != wantDescription || parsed.Properties["description"]["default"] != "keep me" || got.Description != "saved spawn instructions" { + t.Fatalf("legacy description upgrade changed user edits: %+v", got) + } +} diff --git a/server/server.go b/server/server.go index 5a97888..386c970 100644 --- a/server/server.go +++ b/server/server.go @@ -19,16 +19,15 @@ import ( "sync" "time" + "normasdk/llm" + actool "normasdk/tool" + "normasdk/transcript" "pta/agent" "pta/db" "pta/intercept" "pta/llmpool" - "pta/llmrec" "pta/report" "pta/traffic" - "normasdk/llm" - actool "normasdk/tool" - "normasdk/transcript" ) // BuildVersion is the backend application version, injected from cmd/jcyber at @@ -51,15 +50,17 @@ type Server struct { // free slots off the same snapshot and over-promote past the limit. concMu sync.Mutex - cfgMu sync.Mutex - chatAgent *agent.ChatAgent // conversational runner for the chat page; nil w/o LLM + cfgMu sync.Mutex + profileReapplyMu sync.RWMutex + chatAgent *agent.ChatAgent // conversational runner for the chat page; nil w/o LLM // llmProv is the fully-decorated global provider (recorder + failover chain) // installed by applyLLM. Task routers use it after an explicit chain is cleared. - llmProv llm.Provider - llmDirect llm.Provider // concrete global provider, before any failover pool - llmCfg agent.Config // current LLM config (key not exposed) - llmOn bool - llmProf string // active LLM profile name (for llmrec tagging) + llmProv llm.Provider + llmDirect llm.Provider // concrete global provider, before any failover pool + llmCfg agent.Config // current LLM config (key not exposed) + llmProfileID int64 + llmOn bool + llmProf string // active LLM profile name (for llmrec tagging) // chatBusy guards the per-task main-agent run: the chat handler launches the // agent on the server's background ctx (not the request ctx) and returns @@ -93,14 +94,11 @@ type Server struct { profMu sync.Mutex 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. applyLLM also resolves the persisted global-active profile through this - // cache, so global fallback and task chains do not accidentally double the - // configured request rate. Cleared on profile edits, then repopulated by active reapply. - provCacheMu sync.Mutex - provByProfile map[int64]*provEntry - provCacheGen uint64 + provCacheMu sync.Mutex + provByProfile map[int64]*provEntry + provByEffort map[effortProfileKey]*provEntry + provShared map[int64]*profileEffortRouter + globalByEffort map[string]*provEntry // llmHealth is the process-wide circuit-breaker state for LLM failover (轮询). // It deliberately lives OUTSIDE the provider caches: rebuilding the chain @@ -123,8 +121,13 @@ type Server struct { // provEntry is a cached provider + its config for one LLM profile id. type provEntry struct { - prov llm.Provider - cfg agent.Config + prov llm.Provider + cfg agent.Config + baseCfg agent.Config + retryOverride db.RetryOverride + name string + priority int + poolExclude bool } // triggeredRun is one queued P3 trigger fire awaiting its turn for an agent. @@ -149,7 +152,9 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi chatCancel: map[string]context.CancelCauseFunc{}, triggerQ: map[string][]triggeredRun{}, triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{}, profChatAgents: map[int64]*agent.ChatAgent{}, - provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg), + provByProfile: map[int64]*provEntry{}, provByEffort: map[effortProfileKey]*provEntry{}, + provShared: map[int64]*profileEffortRouter{}, + globalByEffort: map[string]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg), taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)} s.initSideQuestions() // 熔断阈值/冷却是失败路径上的热参数,启动时把全局重试策略推给 Registry 一次; @@ -337,14 +342,11 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) { if cfg.APIKey == "" { return cfg, false } - s.cfgMu.Lock() - s.llmProf = p.Name - s.cfgMu.Unlock() return cfg, true } // saveLLMConfig persists the LLM config as the active "default" profile in PG. -func (s *Server) saveLLMConfig(cfg agent.Config) error { +func (s *Server) saveLLMConfig(cfg agent.Config) (int64, error) { // cfg.Provider() already returns one of the three valid format strings // (anthropic / openai / openai-responses), matching the DB CHECK constraint. format := cfg.Provider() @@ -379,24 +381,66 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error { Retry: retry, }) if err != nil { - return err + return 0, err } - return s.m.pg.SetActiveProfile(newID) + return newID, s.m.pg.SetActiveProfile(newID) } // reapplyActiveProfile hot-reloads the engine from the active DB profile so that // saving or activating a profile takes effect without a restart. Best-effort: // logs on failure and leaves the running engine untouched. func (s *Server) reapplyActiveProfile() { - cfg, ok := s.loadLLMConfig() - if !ok { - return + s.profileReapplyMu.Lock() + applied := false + defer func() { + s.profileReapplyMu.Unlock() + if applied { + s.invalidateTaskAgents() + } + }() + applied = s.reapplyActiveProfileWithoutTaskAgentInvalidation() +} + +func (s *Server) reapplyActiveProfileWithoutTaskAgentInvalidation() bool { + active, err := s.m.pg.ActiveProfile() + if err != nil { + return false } - if err := s.applyLLM(cfg); err != nil { - log.Printf("[engine] reapply active profile failed: %v", err) - return + s.cfgMu.Lock() + appliedID := s.llmProfileID + s.cfgMu.Unlock() + profiles := []*db.LLMProfile{} + if active != nil { + profiles = append(profiles, active) + } + if appliedID != 0 && (active == nil || active.ID != appliedID) { + if profile, err := s.m.pg.ProfileByID(appliedID); err == nil && profile != nil { + profiles = append(profiles, profile) + } + } + for _, profile := range profiles { + cfg, ok := s.loadProfileConfig(profile.ID) + if !ok { + continue + } + if err := s.applyLLMWithoutTaskAgentInvalidation(cfg); err != nil { + log.Printf("[engine] reapply profile %d failed: %v", profile.ID, err) + continue + } + log.Printf("[engine] LLM reapplied from profile %d: %s / %s", profile.ID, cfg.Provider(), cfg.Model) + return true + } + s.cfgMu.Lock() + cfg, on := s.llmCfg, s.llmOn + s.cfgMu.Unlock() + if on { + if err := s.applyLLMWithoutTaskAgentInvalidation(cfg); err == nil { + return true + } else { + log.Printf("[engine] reapply running LLM failed: %v", err) + } } - log.Printf("[engine] LLM reapplied from active profile: %s / %s", cfg.Provider(), cfg.Model) + return false } // webSearchFor gates the global web-search opts (backend/key) by an agent's own @@ -423,38 +467,100 @@ func maxTokensResolver(cfg agent.Config) func() int { return func() int { return maxTokens } } +func (s *Server) profileConfigMatches(id int64, cfg agent.Config) bool { + profileCfg, ok := s.loadProfileConfig(id) + return ok && profileCfg == cfg +} + // applyLLM (re)builds the planner/worker/main-agent from cfg and installs them on // the running engine as the GLOBAL active pair. Safe to call at runtime (UI configures LLM). func (s *Server) applyLLM(cfg agent.Config) error { + s.profileReapplyMu.Lock() + applied := false + defer func() { + s.profileReapplyMu.Unlock() + if applied { + s.invalidateTaskAgents() + } + }() + if err := s.applyLLMWithoutTaskAgentInvalidation(cfg); err != nil { + return err + } + applied = true + return nil +} + +func (s *Server) rebuildCurrentLLM() error { + s.profileReapplyMu.Lock() + applied := false + defer func() { + s.profileReapplyMu.Unlock() + if applied { + s.invalidateTaskAgents() + } + }() + s.cfgMu.Lock() + cfg, on := s.llmCfg, s.llmOn + s.cfgMu.Unlock() + if !on { + return nil + } + if err := s.applyLLMWithoutTaskAgentInvalidation(cfg); err != nil { + return err + } + applied = true + return nil +} + +func (s *Server) applyLLMWithoutTaskAgentInvalidation(cfg agent.Config) error { + providerCfg := cfg + providerCfg.RatePerSecond, providerCfg.RatePerMinute = 0, 0 + if _, err := providerCfg.NewProvider(); err != nil { + return err + } + s.cfgMu.Lock() var prov llm.Provider + var appliedProfileID int64 + reusedApplied := false // A persisted active profile must use the same cached provider as task chains // and Agent bindings, otherwise each path owns a separate rate limiter. - if active, _ := s.m.pg.ActiveProfile(); active != nil { - if activeCfg, ok := s.loadProfileConfig(active.ID); ok && activeCfg == cfg { - prov, _, _ = s.providerForProfile(active.ID) + if active, _ := s.m.pg.ActiveProfile(); active != nil && s.profileConfigMatches(active.ID, cfg) { + appliedProfileID = active.ID + } else if s.llmProfileID != 0 && s.profileConfigMatches(s.llmProfileID, cfg) { + appliedProfileID = s.llmProfileID + } + if appliedProfileID != 0 { + prov, _, _ = s.providerForProfileEffortUnlocked(appliedProfileID, "") + if prov == nil { + appliedProfileID = 0 } } + if prov == nil && s.llmOn && s.llmProfileID != 0 && s.llmCfg == cfg && s.llmDirect != nil { + prov = s.llmDirect + appliedProfileID = s.llmProfileID + reusedApplied = true + } if prov == nil { - var err error - prov, err = cfg.NewProvider() - if err != nil { - return err + if s.llmOn && s.llmProfileID == 0 && s.llmCfg == cfg && s.llmDirect != nil { + prov = s.llmDirect + } else { + s.resetEnvironmentEffortProviders() + var ok bool + prov, _, ok = s.providerForEffortUnlocked(0, "", &cfg, "") + if !ok { + s.cfgMu.Unlock() + return fmt.Errorf("provider init failed for %s / %s", cfg.Provider(), cfg.Model) + } } - // Non-persisted/env configs do not have a profile cache key. - s.cfgMu.Lock() - profName := s.llmProf - s.cfgMu.Unlock() - prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, cfg.ThinkingType, cfg.ReasoningEffort, s.m.LLMRecordEnabled) - prov = bindSideProvider(prov, cfg, 0, profName) + } else if s.llmProfileID == 0 { + s.resetEnvironmentEffortProviders() } - s.cfgMu.Lock() s.llmDirect = prov - s.cfgMu.Unlock() // LLM 轮询(默认关):把激活配置包进故障转移链,当前配置不可用时自动切下一个。 // 只影响「走全局激活配置」的这条路径——agent 绑定 / 任务 pin 的走 providerForProfile, // 默认仍然独占该配置(见 poolForBinding)。关闭或无备选时返回原 provider,行为不变。 - if act, err := s.m.pg.ActiveProfile(); err == nil && act != nil { - prov = s.poolForActive(act.ID, prov, cfg) + if appliedProfileID != 0 { + prov = s.poolForActive(appliedProfileID, prov, cfg) } // No global planner/worker pair: every task runs on its own task-routed pair // (agentsForTask), resolved through the engine's authoritative resolver. Global @@ -466,7 +572,6 @@ func (s *Server) applyLLM(cfg agent.Config) error { // per-agent binding is resolved at Chat time (runConversationSync → // chatAgentForProfile). The main-agent has no global instance: each task builds its // own via agentsForTask (task-routed), so nothing is constructed for it here. - s.cfgMu.Lock() // chat agent serves MANY custom agents by key → it holds the GLOBAL opts // (backend/key) and gates Enabled per-conversation-agent at Chat time. 对话始终用激活配置。 s.chatAgent = agent.NewChatAgent(prov, cfg.Model, s.m.dir, tx, win) // chat page runner @@ -476,11 +581,22 @@ func (s *Server) applyLLM(cfg agent.Config) error { s.chatAgent.SetNonStreaming(nonStreamingResolver(cfg)) s.chatAgent.SetMaxTokens(maxTokensResolver(cfg)) s.chatAgent.SetNoaEnabled(s.m.NoaCompactionEnabled) // 实验功能:noa 上下文压缩(每 run 读) + configChanged := !s.llmOn || s.llmCfg != cfg || s.llmProfileID != appliedProfileID s.llmProv = prov s.llmCfg = cfg + s.llmProfileID = appliedProfileID s.llmOn = true + if appliedProfileID == 0 { + s.llmProf = "" + } else if !reusedApplied { + if profile, err := s.m.pg.ProfileByID(appliedProfileID); err == nil && profile != nil { + s.llmProf = profile.Name + } + } + if configChanged { + s.resetGlobalAutoProvider() + } s.cfgMu.Unlock() - s.invalidateTaskAgents() // wake the active task so a task created while idle starts exploring. if t := s.m.ActiveTask(); t != nil { @@ -560,52 +676,21 @@ func (s *Server) chatUnavailableReason() string { // 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 - } - generation := s.provCacheGen - s.provCacheMu.Unlock() - cfg, ok := s.loadProfileConfig(id) - if !ok { - 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, 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, cfg.ThinkingType, cfg.ReasoningEffort, s.m.LLMRecordEnabled) - prov = bindSideProvider(prov, cfg, id, p.Name) - } - s.provCacheMu.Lock() - if generation != s.provCacheGen { - s.provCacheMu.Unlock() - return s.providerForProfile(id) - } - 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 + return s.providerForProfileEffort(id, "") } // chatAgentForProfile returns a ChatAgent built from a specific LLM profile, cached // per profile id. Returns nil if the profile is missing or has no API key. func (s *Server) chatAgentForProfile(id int64) *agent.ChatAgent { + s.profileReapplyMu.RLock() + defer s.profileReapplyMu.RUnlock() s.profMu.Lock() cached := s.profChatAgents[id] s.profMu.Unlock() if cached != nil { return cached } - prov, cfg, ok := s.providerForProfile(id) + prov, cfg, ok := s.providerForProfileEffortUnlocked(id, "") if !ok { return nil } @@ -627,20 +712,45 @@ func (s *Server) chatAgentForProfile(id int64) *agent.ChatAgent { return ca } -// invalidateProfileAgents drops the per-profile ChatAgent + provider caches so a profile -// save/activate/delete — or an agent's binding change — rebuilds pinned tasks' agents -// (and re-resolves each agent's bound model) on their next round. func (s *Server) invalidateProfileAgents() { - s.profMu.Lock() - s.profChatAgents = map[int64]*agent.ChatAgent{} - s.profMu.Unlock() + s.profileReapplyMu.Lock() + s.invalidateProfileChatAgents() s.provCacheMu.Lock() - s.provCacheGen++ - s.provByProfile = map[int64]*provEntry{} + s.globalByEffort = map[string]*provEntry{} s.provCacheMu.Unlock() + s.profileReapplyMu.Unlock() s.invalidateTaskAgents() } +func (s *Server) invalidateProfileProvider(id int64) { + s.profileReapplyMu.Lock() + s.invalidateProfileProviderLocked(id, true) + s.profileReapplyMu.Unlock() + s.invalidateTaskAgents() +} + +func (s *Server) invalidateProfileProviderLocked(id int64, dropLimiter bool) { + s.invalidateProfileChatAgents() + s.provCacheMu.Lock() + delete(s.provByProfile, id) + for key := range s.provByEffort { + if key.id == id { + delete(s.provByEffort, key) + } + } + if dropLimiter { + delete(s.provShared, id) + } + s.globalByEffort = map[string]*provEntry{} + s.provCacheMu.Unlock() +} + +func (s *Server) invalidateProfileChatAgents() { + s.profMu.Lock() + s.profChatAgents = map[int64]*agent.ChatAgent{} + s.profMu.Unlock() +} + func (s *Server) chatAgentRef() *agent.ChatAgent { s.cfgMu.Lock() defer s.cfgMu.Unlock() @@ -1345,16 +1455,27 @@ func (s *Server) setLLM(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, "provider init failed: "+err.Error()) return } - if err := s.saveLLMConfig(cfg); err != nil { + s.profileReapplyMu.Lock() + rebuildTaskAgents := false + defer func() { + s.profileReapplyMu.Unlock() + if rebuildTaskAgents { + s.invalidateTaskAgents() + } + }() + id, err := s.saveLLMConfig(cfg) + if err != nil { writeErr(w, 500, "persist provider failed: "+err.Error()) return } - s.invalidateProfileAgents() - if _, ok := s.loadLLMConfig(); !ok { + rebuildTaskAgents = true + s.invalidateProfileProviderLocked(id, true) + savedCfg, ok := s.loadLLMConfig() + if !ok { writeErr(w, 500, "saved provider is unavailable") return } - if err := s.applyLLM(cfg); err != nil { + if err := s.applyLLMWithoutTaskAgentInvalidation(savedCfg); err != nil { writeErr(w, 400, "provider init failed: "+err.Error()) return } @@ -1443,7 +1564,7 @@ func truncateReply(s string) string { } type createTaskReq struct { - Name string `json:"name,omitempty"` // 可选任务名称;省略/空=未命名 + Name string `json:"name"` CategoryID *int64 `json:"category_id,omitempty"` Description string `json:"description"` Goal string `json:"goal"` @@ -1453,8 +1574,9 @@ type createTaskReq struct { CompanyIDs []int64 `json:"company_ids,omitempty"` // 关联企业范围并快照关联当前企业资产;不复制资产或强制生成意图 TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时 PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600 - SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。 + SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图,让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。 CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。 + ThinkingEffort string `json:"thinking_effort,omitempty"` // InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。 InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"` } @@ -1465,12 +1587,17 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, "bad json: "+err.Error()) return } - if strings.TrimSpace(req.Description) == "" { - req.Description = "Untitled Task" - } if len(req.LLMProfileIDs) == 0 && req.LLMProfileID != nil { req.LLMProfileIDs = []int64{*req.LLMProfileID} } + switch req.ThinkingEffort { + case "": + req.ThinkingEffort = ThinkingEffortMax + case ThinkingEffortMax, ThinkingEffortAuto: + default: + writeErr(w, 400, `thinking_effort must be "auto" or "max"`) + return + } if err := s.validateTaskProfileIDs(req.LLMProfileIDs); err != nil { writeErr(w, 400, err.Error()) return @@ -1511,6 +1638,7 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) { t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{ Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID, SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs, + ThinkingEffort: req.ThinkingEffort, TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds, CoverageEnabled: req.CoverageEnabled, InterceptRules: interceptRules, @@ -1530,7 +1658,7 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) { log.Printf("[task] New Task #%s «%s» goal: %s", t.ID, req.Description, req.Goal) // 共享的建后流程(seed + 种子意图 + 后台目标分解 + engine.Run),与 spawn_task 复用同一段。 // launchTask 内部异步,不阻塞 UI —— 目标分解在后台可见地进行。 - s.launchTask(t, req.Description+" "+req.Goal, req.SeedFirstIntent != nil && *req.SeedFirstIntent) + s.launchTask(t, req.SeedFirstIntent != nil && *req.SeedFirstIntent) writeJSON(w, 201, taskDTO(t, s.resolvedTaskStatus(t))) } @@ -1691,7 +1819,7 @@ func (s *Server) seed(t *Task, text string) { rootID, _ = as.UpsertRootDomain(db.UpsertRootDomainReq{Domain: host, TaskID: taskID}) } if rootID > 0 { - _ = as.SetTaskAssetSource(taskID, rootID, "task", "Initialized from task description or goal", nil) + _ = as.SetTaskAssetSource(taskID, rootID, "task", "Initialized from task target", nil) } } // anchor the seeded assets to this task's begin root as lineage/provenance @@ -1707,13 +1835,13 @@ func (s *Server) seed(t *Task, text string) { // 消费掉而绕过 Run 的门控 → 种子任务仍误触发首轮。 } -// seedFirstIntent writes ONE open intent (summary = 描述+目标) into the task's +// seedFirstIntent writes ONE open intent into the task's // frontier at creation, so a worker can claim and run it immediately without first // waiting a planner round. Mirrors a planner top-level intent: it links from the // origin fact (RelDerivedFrom) so it still traces back to a fact node. Best-effort — // a failure just falls back to the normal planner-driven flow. func (s *Server) seedFirstIntent(t *Task) { - summary := fmt.Sprintf("Complete task goal: %s (task: %s)", t.Goal, t.Description) + summary := fmt.Sprintf("Complete task goal: %s (task: %s)", t.Goal, strings.TrimSpace(taskCreationName(t)+" "+t.Description)) id, err := t.Store.AddIntent(map[string]any{"summary": summary}, 8, nil, "seed") if err != nil { log.Printf("[seed] task %s: failed to issue seed intent: %v", t.ID, err) @@ -3542,14 +3670,9 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { } if changed { // rebuild agents so the new proxy/tools/prompt/web-search take hold (only if LLM configured). - s.cfgMu.Lock() - cfg, on := s.llmCfg, s.llmOn - s.cfgMu.Unlock() - if on { - if err := s.applyLLM(cfg); err != nil { - writeErr(w, 500, err.Error()) - return - } + if err := s.rebuildCurrentLLM(); err != nil { + writeErr(w, 500, err.Error()) + return } } writeJSON(w, 200, s.settingsPayload()) diff --git a/server/server_mgmt.go b/server/server_mgmt.go index ae8cc45..b7fce9d 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -23,10 +23,10 @@ import ( "unicode" "unicode/utf8" - "pta/agent" - "pta/db" "normasdk/llm" "normasdk/skill" + "pta/agent" + "pta/db" ) // validSkillName checks the agentskills.io name constraints, widened so a skill can @@ -441,8 +441,6 @@ func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) { return } } - // 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() } @@ -450,12 +448,7 @@ func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) { // web search). Rebuild them even when no global profile is configured. s.invalidateTaskAgents() // 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() - if on { - _ = s.applyLLM(cfg) - } + _ = s.rebuildCurrentLLM() resp := map[string]any{"ok": true} if req.MaxTurns != nil { resp["max_turns"] = *req.MaxTurns @@ -1818,19 +1811,44 @@ func (s *Server) pgSaveProfile(w http.ResponseWriter, r *http.Request) { if p.Format != "openai" || p.MaxTokensField != llm.MaxTokensFieldCompletion { p.MaxTokensField = "" } + s.profileReapplyMu.Lock() + rebuildTaskAgents := false + defer func() { + s.profileReapplyMu.Unlock() + if rebuildTaskAgents { + s.invalidateTaskAgents() + } + }() id, err := pg.SaveProfile(&p) if err != nil { writeErr(w, 500, err.Error()) return } + cfg, ok := s.loadProfileConfig(id) + if !ok { + writeJSON(w, 200, map[string]any{"id": id}) + return + } + providerCfg := cfg + providerCfg.RatePerSecond, providerCfg.RatePerMinute = 0, 0 + if _, err := providerCfg.NewProvider(); err != nil { + log.Printf("[engine] profile %d saved without reapply: %v", id, err) + writeJSON(w, 200, map[string]any{"id": id}) + return + } + rebuildTaskAgents = true // Editing a profile rebuilds any task pinned to it on its next round. Reapply // the active profile too: the global fallback and explicit task chains must // repopulate the same provider-cache entry and therefore share one limiter. - s.invalidateProfileAgents() + s.provCacheMu.Lock() + shared := s.provShared[id] + keepLimiter := shared != nil && shared.rateSecond == cfg.RatePerSecond && shared.rateMinute == cfg.RatePerMinute + s.provCacheMu.Unlock() + s.invalidateProfileProviderLocked(id, !keepLimiter) // Hot-apply. Not just when the ACTIVE profile changed: with failover on, any // profile's key/model/priority/pool_exclude edit reshapes the chain, so the // engine's provider has to be rebuilt either way. - s.reapplyActiveProfile() + s.reapplyActiveProfileWithoutTaskAgentInvalidation() writeJSON(w, 200, map[string]any{"id": id}) } @@ -1857,13 +1875,23 @@ func (s *Server) pgSaveLLMRetryPolicy(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, err.Error()) return } + s.profileReapplyMu.Lock() + rebuildTaskAgents := false + defer func() { + s.profileReapplyMu.Unlock() + if rebuildTaskAgents { + s.invalidateTaskAgents() + } + }() if err := pg.SetLLMRetryPolicy(pol); err != nil { writeErr(w, 500, err.Error()) return } s.applyRetryPolicy() - s.invalidateProfileAgents() - s.reapplyActiveProfile() + rebuildTaskAgents = s.refreshRetryPolicyProviders() + if s.reapplyActiveProfileWithoutTaskAgentInvalidation() { + rebuildTaskAgents = true + } writeJSON(w, 200, pg.LLMRetryPolicy()) } @@ -1888,10 +1916,9 @@ func (s *Server) pgDeleteProfile(w http.ResponseWriter, r *http.Request) { } return } - s.invalidateProfileAgents() // drop cached agents for the removed profile - s.llmHealth.Reset(id) // its breaker state is meaningless now (row is FK-cascaded away) + s.invalidateProfileProvider(id) // drop cached agents for the removed profile + s.llmHealth.Reset(id) // its breaker state is meaningless now (row is FK-cascaded away) s.restoreTasksAfterProfileDelete(pg) - // Cache invalidation also removed the active profile's shared provider entry. // Reapply after task-state sync so the wake-up observes the post-delete chain. s.reapplyActiveProfile() writeJSON(w, 200, map[string]any{"deleted": id}) @@ -1929,12 +1956,21 @@ func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, err.Error()) return } + s.profileReapplyMu.Lock() + rebuildTaskAgents := false + defer func() { + s.profileReapplyMu.Unlock() + if rebuildTaskAgents { + s.invalidateTaskAgents() + } + }() if err := pg.SetActiveProfile(body.ID); err != nil { writeErr(w, 500, err.Error()) return } - s.invalidateProfileAgents() // active change may affect pinned-task fallbacks - s.reapplyActiveProfile() // switch the running engine to the newly activated profile + rebuildTaskAgents = true + s.invalidateProfileChatAgents() + s.reapplyActiveProfileWithoutTaskAgentInvalidation() writeJSON(w, 200, map[string]any{"ok": true}) } diff --git a/server/side_questions.go b/server/side_questions.go index fba4b60..6a4d77f 100644 --- a/server/side_questions.go +++ b/server/side_questions.go @@ -15,12 +15,12 @@ import ( "sync" "time" + "normasdk/llm" + "normasdk/transcript" "pta/agent" "pta/db" "pta/llmrec" "pta/sidequestion" - "normasdk/llm" - "normasdk/transcript" ) type sideRun struct { @@ -50,6 +50,15 @@ func bindSideProvider(p llm.Provider, cfg agent.Config, id int64, name string) l return sidequestion.Bind(p, sideModel(cfg, id, name)) } +func sideModelEffort(cfg agent.Config, model sidequestion.Model) (string, bool) { + for _, effort := range []string{"", ThinkingEffortAuto} { + if sideModel(resolvedEffortConfig(cfg, effort), model.ProfileID, model.Name).Identity == model.Identity { + return effort, true + } + } + return "", false +} + func (s *Server) initSideQuestions() { s.side = &sideQuestionState{pending: map[string]sidequestion.Snapshot{}, latest: map[string]sidequestion.Snapshot{}, seen: map[string][2]int64{}, runs: map[string]sideRun{}, done: make(chan struct{})} if n, err := strconv.Atoi(os.Getenv("JCYBER_BTW_MAX_OUTPUT_TOKENS")); err == nil && n >= 256 && n <= 32768 { @@ -186,20 +195,26 @@ func (s *Server) cancelWorkerSide(taskID string, expID, iid int64) { } func (s *Server) sideProvider(model sidequestion.Model) (llm.Provider, error) { - var p llm.Provider - var cfg agent.Config - var ok bool if model.ProfileID > 0 { - // Validate the persisted reference even if a previous provider is cached. - current, exists := s.loadProfileConfig(model.ProfileID) - if !exists || sideModel(current, model.ProfileID, model.Name).Identity != model.Identity { + s.profileReapplyMu.RLock() + defer s.profileReapplyMu.RUnlock() + profile, err := s.m.pg.ProfileByID(model.ProfileID) + if err != nil || profile == nil { return nil, errors.New("Model profile was deleted or changed; run the main Agent first to update the context") } - p, cfg, ok = s.providerForProfile(model.ProfileID) - } else { - s.cfgMu.Lock() - p, cfg, ok = s.llmDirect, s.llmCfg, s.llmOn - s.cfgMu.Unlock() + for _, effort := range []string{"", ThinkingEffortAuto} { + prov, cfg, ok := s.providerForProfileEffortUnlocked(model.ProfileID, effort) + if ok && prov != nil && sideModel(cfg, model.ProfileID, model.Name).Identity == model.Identity { + return prov, nil + } + } + return nil, errors.New("Model profile was deleted or changed; run the main Agent first to update the context") + } + s.cfgMu.Lock() + p, cfg, ok := s.llmDirect, s.llmCfg, s.llmOn + s.cfgMu.Unlock() + if effort, matched := sideModelEffort(cfg, model); matched && effort != "" { + p, cfg, ok = s.globalDirectAutoProvider() } if !ok || p == nil || sideModel(cfg, model.ProfileID, model.Name).Identity != model.Identity { return nil, errors.New("Model profile was deleted or changed; run the main Agent first to update the context") diff --git a/server/side_questions_test.go b/server/side_questions_test.go index cde0c04..e8653d8 100644 --- a/server/side_questions_test.go +++ b/server/side_questions_test.go @@ -14,11 +14,11 @@ import ( "testing" "time" + "normasdk/harness" + "normasdk/llm" "pta/agent" "pta/db" "pta/sidequestion" - "normasdk/harness" - "normasdk/llm" ) type sideHTTPProvider struct { @@ -413,7 +413,7 @@ func TestSideCheckpointPersistsBeforeAdmissionAndRestart(t *testing.T) { } } -func TestSideRejectsDeletedOrChangedCachedProfile(t *testing.T) { +func TestSideUsesAppliedCachedProfileUntilChangedOrDeleted(t *testing.T) { f := newSideHTTPFixture(t) p := &db.LLMProfile{Name: "btw-cached-profile", Format: "openai", BaseURL: "http://fixture.invalid", APIKey: "test-only", Model: "fixture", Streaming: true} id, err := f.m.pg.SaveProfile(p) @@ -433,8 +433,16 @@ func TestSideRejectsDeletedOrChangedCachedProfile(t *testing.T) { if _, err = f.m.pg.Exec(`UPDATE llm_profiles SET model='changed' WHERE id=$1`, id); err != nil { t.Fatal(err) } + if _, err = f.s.sideProvider(model); err != nil { + t.Fatalf("unapplied draft invalidated running profile: %v", err) + } + changed, ok := f.s.loadProfileConfig(id) + if !ok { + t.Fatal("changed profile unavailable") + } + f.s.provByProfile[id] = &provEntry{prov: f.provider, cfg: changed} if _, err = f.s.sideProvider(model); err == nil { - t.Fatal("cached profile bypassed model identity check") + t.Fatal("applied model change retained old snapshot") } if err = f.m.pg.DeleteProfile(id); err != nil { t.Fatal(err) @@ -444,6 +452,36 @@ func TestSideRejectsDeletedOrChangedCachedProfile(t *testing.T) { } } +func TestSideAcceptsTaskEffortProfile(t *testing.T) { + f := newSideHTTPFixture(t) + p := &db.LLMProfile{Name: "btw-task-effort", Format: "openai", BaseURL: "http://fixture.invalid", APIKey: "test-only", Model: "fixture", ReasoningEffort: "high", Streaming: true} + id, err := f.m.pg.SaveProfile(p) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = f.m.pg.DeleteProfile(id) }) + cfg, ok := f.s.loadProfileConfig(id) + if !ok { + t.Fatal("profile unavailable") + } + for _, effort := range []string{ThinkingEffortAuto, ThinkingEffortMax} { + model := sideModel(resolvedEffortConfig(cfg, effort), id, p.Name) + if prov, err := f.s.sideProvider(model); err != nil || prov == nil { + t.Fatalf("side provider for %s = %v, %v", effort, prov, err) + } + } + f.s.provCacheMu.Lock() + delete(f.s.provByProfile, id) + f.s.provCacheMu.Unlock() + if _, err := f.m.pg.Exec(`UPDATE llm_profiles SET model='draft', proxy='://invalid' WHERE id=$1`, id); err != nil { + t.Fatal(err) + } + autoModel := sideModel(resolvedEffortConfig(cfg, ThinkingEffortAuto), id, p.Name) + if prov, err := f.s.sideProvider(autoModel); err != nil || prov == nil { + t.Fatalf("auto-only applied side provider = %v, %v", prov, err) + } +} + func TestSideTaskDrainPersistsBeforeArchive(t *testing.T) { f := newSideHTTPFixture(t) task, err := f.m.CreateTask("side archive drain", "context", nil, 0, 0) diff --git a/server/task_admission_test.go b/server/task_admission_test.go index 31f4a5f..f277454 100644 --- a/server/task_admission_test.go +++ b/server/task_admission_test.go @@ -571,6 +571,48 @@ func TestTaskLLMResolutionRejectsInvalidExplicitProfile(t *testing.T) { } } +func TestTaskLLMResolutionUsesAppliedAutoProfile(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer m.Close() + + name := fmt.Sprintf("applied-auto-resolution-%d", time.Now().UnixNano()) + profileID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: name, Format: "openai-responses", Model: "applied-model", APIKey: "test-key", Streaming: true, + }) + if err != nil { + t.Fatal(err) + } + task, err := m.CreateTaskWithOptions("auto resolution", "keep applied model", db.TaskCreateOptions{ + LLMProfileIDs: []int64{profileID}, ThinkingEffort: ThinkingEffortAuto, + }) + if err != nil { + _ = m.pg.DeleteProfile(profileID) + t.Fatal(err) + } + defer func() { + _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) + _ = m.pg.DeleteProfile(profileID) + }() + + s := &Server{m: m} + if _, _, ok := s.providerForProfileEffort(profileID, ThinkingEffortAuto); !ok { + t.Fatal("applied auto provider unavailable") + } + if _, err := m.pg.Exec(`UPDATE llm_profiles SET name=$2, model='draft-model', proxy='://invalid' WHERE id=$1`, profileID, "draft-"+name); err != nil { + t.Fatal(err) + } + got, err := s.resolveTaskRoleLLM(task, "unbound-auto-resolution-role") + if err != nil { + t.Fatal(err) + } + if !got.Available || got.Source != "task_chain" || got.Name != name || got.Model != "applied-model" { + t.Fatalf("resolution=%+v, want applied auto profile", got) + } +} + // 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) { diff --git a/server/task_create_name_test.go b/server/task_create_name_test.go new file mode 100644 index 0000000..be15ea7 --- /dev/null +++ b/server/task_create_name_test.go @@ -0,0 +1,60 @@ +package server + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestCreateTaskWithoutNameSeedsGoal(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + s := newAdmissionTestServer(m, func(*Task) bool { return false }) + host := fmt.Sprintf("unnamed-%d.example.test", time.Now().UnixNano()) + body, err := json.Marshal(map[string]string{ + "description": "Use https://docs.vendor.net as reference", + "goal": "Map http://" + host, + }) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + s.createTask(recorder, httptest.NewRequest(http.MethodPost, "/api/tasks", bytes.NewReader(body))) + if recorder.Code != http.StatusCreated { + t.Fatalf("create unnamed task: %d %s", recorder.Code, recorder.Body.String()) + } + var created struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + if created.ID == "" || created.Name != "" { + t.Fatalf("unnamed task = %+v", created) + } + defer func() { _, _ = m.DeleteTask(created.ID, DeleteTaskOptions{DeleteAssets: true}) }() + assets, err := m.Assets().QueryByTask(mustTaskID(t, created.ID), "", 20, 0) + if err != nil { + t.Fatal(err) + } + goalFound := false + for _, asset := range assets { + if asset.URL == "http://"+host { + goalFound = true + } + if asset.URL == "https://docs.vendor.net" { + t.Fatalf("description reference seeded as target: %+v", assets) + } + } + if !goalFound { + t.Fatalf("goal target missing from seeded assets: %+v", assets) + } +} diff --git a/server/task_effort.go b/server/task_effort.go new file mode 100644 index 0000000..b5336d3 --- /dev/null +++ b/server/task_effort.go @@ -0,0 +1,390 @@ +package server + +import ( + "context" + "errors" + "iter" + "log" + + "normasdk/llm" + "pta/agent" + "pta/db" + "pta/llmrec" +) + +const ( + ThinkingEffortMax = "max" + ThinkingEffortAuto = "auto" +) + +type effortProfileKey struct { + id int64 + effort string +} + +type providerContextKey struct{} + +type profileEffortRouter struct { + limited llm.Provider + rateSecond float64 + rateMinute float64 +} + +func (*profileEffortRouter) provider(ctx context.Context) llm.Provider { + prov, _ := ctx.Value(providerContextKey{}).(llm.Provider) + return prov +} + +func (r *profileEffortRouter) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + if prov := r.provider(ctx); prov != nil { + return prov.Stream(ctx, req) + } + return func(yield func(llm.StreamEvent, error) bool) { + yield(llm.StreamEvent{}, errors.New("LLM effort provider unavailable")) + } +} + +func (r *profileEffortRouter) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { + if prov := r.provider(ctx); prov != nil { + return prov.Complete(ctx, req) + } + return llm.Message{}, "", llm.Usage{}, errors.New("LLM effort provider unavailable") +} + +type profileEffortView struct { + shared llm.Provider + provider llm.Provider +} + +func (v *profileEffortView) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + return v.shared.Stream(context.WithValue(ctx, providerContextKey{}, v.provider), req) +} + +func (v *profileEffortView) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { + return v.shared.Complete(context.WithValue(ctx, providerContextKey{}, v.provider), req) +} + +func resolvedEffortConfig(cfg agent.Config, effort string) agent.Config { + if effort == ThinkingEffortAuto { + cfg.ThinkingType = "" + cfg.ReasoningEffort = "" + } + return cfg +} + +func (s *Server) providerForProfileEffort(id int64, effort string) (llm.Provider, agent.Config, bool) { + s.profileReapplyMu.RLock() + defer s.profileReapplyMu.RUnlock() + return s.providerForProfileEffortUnlocked(id, effort) +} + +func (s *Server) providerForProfileEffortUnlocked(id int64, effort string) (llm.Provider, agent.Config, bool) { + return s.providerForEffortUnlocked(id, effort, nil, "") +} + +func (s *Server) cachedProfileEntry(id int64, effort string) *provEntry { + if effort == ThinkingEffortMax { + effort = "" + } + s.provCacheMu.Lock() + defer s.provCacheMu.Unlock() + if effort != "" { + if entry := s.provByEffort[effortProfileKey{id: id, effort: effort}]; entry != nil { + return entry + } + } + if entry := s.provByProfile[id]; entry != nil { + return entry + } + return s.provByEffort[effortProfileKey{id: id, effort: ThinkingEffortAuto}] +} + +func (s *Server) buildEffortProvider(id int64, baseCfg agent.Config, name, effort string) (llm.Provider, agent.Config, error) { + cfg := resolvedEffortConfig(baseCfg, effort) + providerCfg := cfg + providerCfg.RatePerSecond, providerCfg.RatePerMinute = 0, 0 + prov, err := providerCfg.NewProvider() + if err != nil { + return nil, agent.Config{}, err + } + prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, name, cfg.ThinkingType, cfg.ReasoningEffort, s.m.LLMRecordEnabled) + return bindSideProvider(prov, cfg, id, name), cfg, nil +} + +func (s *Server) providerForEffortUnlocked(id int64, effort string, source *agent.Config, name string) (llm.Provider, agent.Config, bool) { + if effort == ThinkingEffortMax { + effort = "" + } + key := effortProfileKey{id: id, effort: effort} + var desired agent.Config + priority := 0 + poolExclude := false + var retryOverride db.RetryOverride + if source != nil { + desired = resolvedEffortConfig(*source, effort) + } + s.provCacheMu.Lock() + var cached *provEntry + if effort == "" { + cached = s.provByProfile[id] + } else { + cached = s.provByEffort[key] + } + if cached != nil && (source == nil || cached.cfg == desired) { + s.provCacheMu.Unlock() + return cached.prov, cached.cfg, true + } + base := s.provByProfile[id] + if base == nil { + base = s.provByEffort[effortProfileKey{id: id, effort: ThinkingEffortAuto}] + } + if base != nil { + priority = base.priority + poolExclude = base.poolExclude + retryOverride = base.retryOverride + if source == nil { + applied := base.baseCfg + source = &applied + name = base.name + desired = resolvedEffortConfig(applied, effort) + } + } + s.provCacheMu.Unlock() + var baseCfg agent.Config + if source == nil { + var ok bool + baseCfg, ok = s.loadProfileConfig(id) + if !ok { + return nil, agent.Config{}, false + } + profile, err := s.m.pg.ProfileByID(id) + if err != nil || profile == nil { + return nil, agent.Config{}, false + } + name = profile.Name + priority = profile.Priority + poolExclude = profile.PoolExclude + retryOverride = profile.Retry + } else { + baseCfg = *source + } + prov, cfg, err := s.buildEffortProvider(id, baseCfg, name, effort) + if err != nil { + if id != 0 { + log.Printf("[engine] build provider for LLM profile %d failed: %v", id, err) + } + return nil, agent.Config{}, false + } + s.provCacheMu.Lock() + if effort == "" { + cached = s.provByProfile[id] + } else { + cached = s.provByEffort[key] + } + if cached != nil && (source == nil || cached.cfg == cfg) { + prov, cfg = cached.prov, cached.cfg + } else { + shared := s.provShared[id] + if shared != nil && (shared.rateSecond != cfg.RatePerSecond || shared.rateMinute != cfg.RatePerMinute) { + delete(s.provByProfile, id) + for other := range s.provByEffort { + if other.id == id { + delete(s.provByEffort, other) + } + } + shared = nil + } + if shared == nil { + shared = &profileEffortRouter{rateSecond: cfg.RatePerSecond, rateMinute: cfg.RatePerMinute} + shared.limited = llm.RateLimited(shared, llm.RateLimit{PerSecond: cfg.RatePerSecond, PerMinute: cfg.RatePerMinute}) + if s.provShared == nil { + s.provShared = map[int64]*profileEffortRouter{} + } + s.provShared[id] = shared + } + prov = &profileEffortView{shared: shared.limited, provider: prov} + entry := &provEntry{prov: prov, cfg: cfg, baseCfg: baseCfg, retryOverride: retryOverride, name: name, priority: priority, poolExclude: poolExclude} + if effort == "" { + if s.provByProfile == nil { + s.provByProfile = map[int64]*provEntry{} + } + s.provByProfile[id] = entry + } else { + if s.provByEffort == nil { + s.provByEffort = map[effortProfileKey]*provEntry{} + } + s.provByEffort[key] = entry + } + if id != 0 { + log.Printf("[engine] built provider for LLM profile %d (%s / %s / %s)", id, cfg.Provider(), cfg.Model, effort) + } + } + s.provCacheMu.Unlock() + return prov, cfg, true +} + +func (s *Server) refreshRetryPolicyProviders() bool { + s.cfgMu.Lock() + appliedID := s.llmProfileID + s.cfgMu.Unlock() + s.provCacheMu.Lock() + variants := map[int64]map[string]*provEntry{} + for id, entry := range s.provByProfile { + if id != 0 { + variants[id] = map[string]*provEntry{"": entry} + } + } + for key, entry := range s.provByEffort { + if key.id != 0 { + if variants[key.id] == nil { + variants[key.id] = map[string]*provEntry{} + } + variants[key.id][key.effort] = entry + } + } + s.provCacheMu.Unlock() + type replacement struct { + prov llm.Provider + cfg agent.Config + } + refreshed := false + var appliedGlobal *provEntry + policy := s.retryPolicy() + for id, entries := range variants { + applied := entries[""] + if applied == nil { + applied = entries[ThinkingEffortAuto] + } + baseCfg := applied.baseCfg + baseCfg.Retry = resolveRetry(applied.retryOverride, policy) + staged := make(map[string]replacement, len(entries)) + for effort := range entries { + prov, cfg, err := s.buildEffortProvider(id, baseCfg, applied.name, effort) + if err != nil { + log.Printf("[engine] rebuild provider for LLM profile %d failed: %v", id, err) + staged = nil + break + } + staged[effort] = replacement{prov: prov, cfg: cfg} + } + if staged == nil { + continue + } + s.provCacheMu.Lock() + shared := s.provShared[id] + if shared == nil || shared.rateSecond != baseCfg.RatePerSecond || shared.rateMinute != baseCfg.RatePerMinute { + s.provCacheMu.Unlock() + continue + } + for effort, built := range staged { + entry := &provEntry{ + prov: &profileEffortView{shared: shared.limited, provider: built.prov}, cfg: built.cfg, + baseCfg: baseCfg, retryOverride: applied.retryOverride, + name: applied.name, priority: applied.priority, poolExclude: applied.poolExclude, + } + if effort == "" { + s.provByProfile[id] = entry + if id == appliedID { + appliedGlobal = entry + } + } else { + key := effortProfileKey{id: id, effort: effort} + s.provByEffort[key] = entry + } + } + s.provCacheMu.Unlock() + s.profMu.Lock() + delete(s.profChatAgents, id) + s.profMu.Unlock() + refreshed = true + } + if refreshed { + s.provCacheMu.Lock() + s.globalByEffort = map[string]*provEntry{} + s.provCacheMu.Unlock() + } + if appliedGlobal != nil { + s.cfgMu.Lock() + if s.llmOn && s.llmProfileID == appliedID { + s.llmCfg = appliedGlobal.cfg + s.llmDirect = appliedGlobal.prov + } + s.cfgMu.Unlock() + } + return refreshed +} + +func (s *Server) resetEnvironmentEffortProviders() { + s.provCacheMu.Lock() + delete(s.provByProfile, 0) + delete(s.provShared, 0) + delete(s.provByEffort, effortProfileKey{effort: ThinkingEffortAuto}) + s.globalByEffort = map[string]*provEntry{} + s.provCacheMu.Unlock() +} + +func (s *Server) resetGlobalAutoProvider() { + s.provCacheMu.Lock() + delete(s.provByEffort, effortProfileKey{id: s.llmProfileID, effort: ThinkingEffortAuto}) + delete(s.globalByEffort, ThinkingEffortAuto) + s.provCacheMu.Unlock() +} + +func (s *Server) globalProviderForEffort(effort string) (llm.Provider, agent.Config, bool) { + s.profileReapplyMu.RLock() + defer s.profileReapplyMu.RUnlock() + return s.resolveGlobalProviderForEffort(effort, true) +} + +func (s *Server) globalDirectAutoProvider() (llm.Provider, agent.Config, bool) { + s.profileReapplyMu.RLock() + defer s.profileReapplyMu.RUnlock() + return s.resolveGlobalProviderForEffort(ThinkingEffortAuto, false) +} + +func (s *Server) resolveGlobalProviderForEffort(effort string, withFailover bool) (llm.Provider, agent.Config, bool) { + if effort == "" || effort == ThinkingEffortMax { + return s.globalProvider() + } + s.cfgMu.Lock() + on, baseCfg, name, appliedProfileID := s.llmOn, s.llmCfg, s.llmProf, s.llmProfileID + if !on { + s.cfgMu.Unlock() + return nil, agent.Config{}, false + } + s.provCacheMu.Lock() + if withFailover { + if e := s.globalByEffort[effort]; e != nil { + s.provCacheMu.Unlock() + s.cfgMu.Unlock() + return e.prov, e.cfg, true + } + } + s.provCacheMu.Unlock() + s.cfgMu.Unlock() + if s.m == nil { + return nil, agent.Config{}, false + } + prov, cfg, ok := s.providerForEffortUnlocked(appliedProfileID, effort, &baseCfg, name) + if !ok { + return nil, agent.Config{}, false + } + if withFailover && appliedProfileID != 0 { + prov = s.poolForActiveEffort(appliedProfileID, prov, cfg, effort) + } + s.provCacheMu.Lock() + if !withFailover { + s.provCacheMu.Unlock() + return prov, cfg, true + } + if e := s.globalByEffort[effort]; e != nil { + prov, cfg = e.prov, e.cfg + } else { + if s.globalByEffort == nil { + s.globalByEffort = map[string]*provEntry{} + } + s.globalByEffort[effort] = &provEntry{prov: prov, cfg: cfg, name: name} + } + s.provCacheMu.Unlock() + return prov, cfg, true +} diff --git a/server/task_effort_test.go b/server/task_effort_test.go new file mode 100644 index 0000000..626ada7 --- /dev/null +++ b/server/task_effort_test.go @@ -0,0 +1,1066 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "normasdk/llm" + "pta/agent" + "pta/db" + "pta/llmpool" +) + +func effortWireServer(t *testing.T) (*httptest.Server, <-chan map[string]any) { + t.Helper() + requests := make(chan map[string]any, 4) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + var data map[string]any + if json.Unmarshal(body, &data) == nil { + requests <- data + } + w.WriteHeader(http.StatusBadRequest) + })) + return srv, requests +} + +func effortWireRequest(t *testing.T, prov llm.Provider, requests <-chan map[string]any) map[string]any { + t.Helper() + _, _, _, _ = prov.Complete(context.Background(), llm.CompletionRequest{}) + select { + case body := <-requests: + return body + case <-time.After(3 * time.Second): + t.Fatal("provider did not send a request") + return nil + } +} + +func assertEffortWire(t *testing.T, body map[string]any, model, effort string) { + t.Helper() + if body["model"] != model { + t.Fatalf("request model = %v, want %s", body["model"], model) + } + reasoning, present := body["reasoning"] + if effort == ThinkingEffortAuto { + if present { + t.Fatalf("auto request sent reasoning: %v", reasoning) + } + return + } + fields, ok := reasoning.(map[string]any) + if !ok || fields["effort"] != effort { + t.Fatalf("request reasoning = %v, want effort %s", reasoning, effort) + } +} + +func TestResolvedEffortConfig(t *testing.T) { + base := agent.Config{ThinkingType: "enabled", ReasoningEffort: "high", Model: "chosen-model"} + auto := resolvedEffortConfig(base, ThinkingEffortAuto) + if auto.ThinkingType != "" || auto.ReasoningEffort != "" || auto.Model != base.Model { + t.Fatalf("auto config = %+v, want provider-default thinking on chosen model", auto) + } + max := resolvedEffortConfig(base, ThinkingEffortMax) + if max != base { + t.Fatalf("max config = %+v, want selected profile config %+v", max, base) + } + disabledBase := agent.Config{ThinkingType: "disabled", ReasoningEffort: "high"} + if disabled := resolvedEffortConfig(disabledBase, ThinkingEffortMax); disabled != disabledBase { + t.Fatalf("disabled profile max config = %+v, want selected profile config %+v", disabled, disabledBase) + } + if legacy := resolvedEffortConfig(base, ""); legacy != base { + t.Fatalf("legacy config = %+v, want unchanged %+v", legacy, base) + } +} + +func TestResolvedEffortWire(t *testing.T) { + srv, requests := effortWireServer(t) + defer srv.Close() + base := agent.Config{Format: llm.FormatOpenAIResponses, BaseURL: srv.URL, APIKey: "test-key", Model: "gpt-5.1", ThinkingType: "enabled", ReasoningEffort: "high"} + for _, effort := range []string{ThinkingEffortAuto, ThinkingEffortMax} { + cfg := resolvedEffortConfig(base, effort) + prov, err := cfg.NewProvider() + if err != nil { + t.Fatal(err) + } + wireEffort := effort + if effort == ThinkingEffortMax { + wireEffort = base.ReasoningEffort + } + assertEffortWire(t, effortWireRequest(t, prov, requests), base.Model, wireEffort) + } +} + +func TestMaxEffortUsesGlobalProvider(t *testing.T) { + direct := &scriptedLLMProvider{} + s := &Server{llmOn: true, llmProv: direct} + provider, _, ok := s.globalProviderForEffort(ThinkingEffortMax) + if !ok { + t.Fatal("max effort did not resolve the global provider") + } + _, _, _, err := provider.Complete(context.Background(), llm.CompletionRequest{}) + if err != nil || direct.calls != 1 { + t.Fatalf("global provider calls = %d, error = %v; want one call without error", direct.calls, err) + } +} + +func TestAutoProfileEffortUsesAppliedCache(t *testing.T) { + srv, requests := effortWireServer(t) + defer srv.Close() + cfg := agent.ConfigFrom("openai-responses", "applied-model", srv.URL, "test-key", "") + cfg.ThinkingType, cfg.ReasoningEffort, cfg.RatePerMinute = "enabled", "high", 1 + s := &Server{m: &Manager{}} + maxProvider, _, ok := s.providerForEffortUnlocked(42, "", &cfg, "applied") + if !ok { + t.Fatal("applied profile provider unavailable") + } + assertEffortWire(t, effortWireRequest(t, maxProvider, requests), cfg.Model, cfg.ReasoningEffort) + autoProvider, autoCfg, ok := s.providerForProfileEffort(42, ThinkingEffortAuto) + if !ok || autoCfg.Model != cfg.Model || autoCfg.ThinkingType != "" || autoCfg.ReasoningEffort != "" { + t.Fatalf("auto profile config = %+v, available = %v; want applied model with provider-default effort", autoCfg, ok) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + _, _, _, err := autoProvider.Complete(ctx, llm.CompletionRequest{}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("auto effort bypassed applied profile limiter: %v", err) + } + select { + case request := <-requests: + t.Fatalf("auto effort sent a second request before rate limit: %v", request) + default: + } +} + +func TestGlobalAutoEffortUsesAppliedConfig(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + srv, requests := effortWireServer(t) + defer srv.Close() + previous, err := m.pg.ActiveProfile() + if err != nil { + t.Fatal(err) + } + profileID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("effort-global-%d", time.Now().UnixNano()), Format: "openai-responses", + BaseURL: srv.URL, Model: "gpt-5.1", APIKey: "test-key", ReasoningEffort: "high", + Streaming: false, MaxTokens: 4096, RatePerMinute: 1, + }) + if err != nil { + t.Fatal(err) + } + defer func() { + if previous != nil { + _ = m.pg.SetActiveProfile(previous.ID) + } else { + _, _ = m.pg.Exec(`UPDATE llm_profiles SET is_default=false WHERE id=$1`, profileID) + } + _ = m.pg.DeleteProfile(profileID) + }() + if err := m.pg.SetActiveProfile(profileID); err != nil { + t.Fatal(err) + } + poolEnabled := m.LLMPoolEnabled() + if err := m.SetLLMPoolEnabled(false); err != nil { + t.Fatal(err) + } + defer func() { _ = m.SetLLMPoolEnabled(poolEnabled) }() + + applied := agent.ConfigFrom("openai-responses", "gpt-5.1", srv.URL, "test-key", "") + applied.ReasoningEffort = "high" + applied.RatePerMinute = 1 + s := &Server{m: m, llmHealth: newLLMHealthRegistry(m.pg)} + if err := s.applyLLM(applied); err != nil { + t.Fatal(err) + } + maxProvider, _, ok := s.globalProviderForEffort(ThinkingEffortMax) + if !ok { + t.Fatal("max effort did not resolve applied provider") + } + autoProvider, autoCfg, ok := s.globalProviderForEffort(ThinkingEffortAuto) + if !ok || autoCfg.Stream != applied.Stream || autoCfg.MaxTokens != applied.MaxTokens { + t.Fatalf("auto config = %+v, available = %v; want applied streaming and output limit", autoCfg, ok) + } + assertEffortWire(t, effortWireRequest(t, maxProvider, requests), applied.Model, applied.ReasoningEffort) + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + _, _, _, err = autoProvider.Complete(ctx, llm.CompletionRequest{}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("auto provider did not share max provider's rate limit: %v", err) + } + select { + case request := <-requests: + t.Fatalf("auto provider sent a second request before rate limit: %v", request) + default: + } +} + +func TestEnvironmentEffortKeepsLimiterAfterInactiveProfileSave(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + previous, err := m.pg.ActiveProfile() + if err != nil { + t.Fatal(err) + } + if previous != nil { + if _, err := m.pg.Exec(`UPDATE llm_profiles SET is_default=false WHERE id=$1`, previous.ID); err != nil { + t.Fatal(err) + } + defer func() { _ = m.pg.SetActiveProfile(previous.ID) }() + } + srv, requests := effortWireServer(t) + defer srv.Close() + cfg := agent.ConfigFrom("openai-responses", "environment-model", srv.URL, "test-key", "") + cfg.ThinkingType, cfg.ReasoningEffort, cfg.RatePerMinute = "enabled", "high", 1 + s := &Server{m: m, llmHealth: newLLMHealthRegistry(m.pg)} + if err := s.applyLLM(cfg); err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{ + "name": fmt.Sprintf("inactive-%d", time.Now().UnixNano()), "format": "openai-responses", + "model": "inactive-model", "base_url": srv.URL, "api_key": "test-key", + }) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + s.pgSaveProfile(recorder, httptest.NewRequest(http.MethodPost, "/api/llm/profiles", bytes.NewReader(body))) + if recorder.Code != http.StatusOK { + t.Fatalf("save inactive profile: %d %s", recorder.Code, recorder.Body.String()) + } + var saved struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &saved); err != nil { + t.Fatal(err) + } + defer func() { _ = m.pg.DeleteProfile(saved.ID) }() + maxProvider, _, ok := s.globalProviderForEffort(ThinkingEffortMax) + if !ok { + t.Fatal("max effort did not resolve environment provider") + } + assertEffortWire(t, effortWireRequest(t, maxProvider, requests), cfg.Model, cfg.ReasoningEffort) + if err := s.rebuildCurrentLLM(); err != nil { + t.Fatal(err) + } + autoProvider, _, ok := s.globalProviderForEffort(ThinkingEffortAuto) + if !ok { + t.Fatal("auto effort did not resolve environment provider") + } + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + _, _, _, err = autoProvider.Complete(ctx, llm.CompletionRequest{}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("auto effort did not share environment limiter: %v", err) + } + select { + case request := <-requests: + t.Fatalf("auto effort sent a second request before rate limit: %v", request) + default: + } +} + +func TestUnchangedActiveProfileKeepsLimiterAfterOtherProfileSave(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + srv, requests := effortWireServer(t) + defer srv.Close() + previous, err := m.pg.ActiveProfile() + if err != nil { + t.Fatal(err) + } + activeID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("active-unchanged-%d", time.Now().UnixNano()), Format: "openai-responses", + BaseURL: srv.URL, Model: "active-model", APIKey: "test-key", RatePerMinute: 1, + ThinkingType: "enabled", ReasoningEffort: "high", Streaming: true, + }) + if err != nil { + t.Fatal(err) + } + var savedID int64 + defer func() { + if previous != nil { + _ = m.pg.SetActiveProfile(previous.ID) + } else { + _, _ = m.pg.Exec(`UPDATE llm_profiles SET is_default=false WHERE id=$1`, activeID) + } + if savedID != 0 { + _ = m.pg.DeleteProfile(savedID) + } + _ = m.pg.DeleteProfile(activeID) + }() + if err := m.pg.SetActiveProfile(activeID); err != nil { + t.Fatal(err) + } + poolEnabled := m.LLMPoolEnabled() + if err := m.SetLLMPoolEnabled(false); err != nil { + t.Fatal(err) + } + defer func() { _ = m.SetLLMPoolEnabled(poolEnabled) }() + s := &Server{m: m, llmHealth: newLLMHealthRegistry(m.pg)} + cfg, ok := s.loadLLMConfig() + if !ok { + t.Fatal("active profile unavailable") + } + if err := s.applyLLM(cfg); err != nil { + t.Fatal(err) + } + maxProvider, _, ok := s.globalProviderForEffort(ThinkingEffortMax) + if !ok { + t.Fatal("max effort did not resolve active provider") + } + assertEffortWire(t, effortWireRequest(t, maxProvider, requests), cfg.Model, cfg.ReasoningEffort) + body, err := json.Marshal(map[string]any{ + "name": fmt.Sprintf("inactive-other-%d", time.Now().UnixNano()), "format": "openai-responses", + "model": "other-model", "base_url": srv.URL, "api_key": "test-key", + }) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + s.pgSaveProfile(recorder, httptest.NewRequest(http.MethodPost, "/api/llm/profiles", bytes.NewReader(body))) + if recorder.Code != http.StatusOK { + t.Fatalf("save other profile: %d %s", recorder.Code, recorder.Body.String()) + } + var saved struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &saved); err != nil { + t.Fatal(err) + } + savedID = saved.ID + autoProvider, autoCfg, ok := s.globalProviderForEffort(ThinkingEffortAuto) + if !ok || autoCfg.Model != cfg.Model { + t.Fatalf("auto config = %+v, available = %v", autoCfg, ok) + } + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + _, _, _, err = autoProvider.Complete(ctx, llm.CompletionRequest{}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("auto effort did not share active profile limiter: %v", err) + } + select { + case request := <-requests: + t.Fatalf("auto effort sent a second request before rate limit: %v", request) + default: + } +} + +func TestInvalidActiveProfileSaveKeepsRunningLimiter(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + srv, requests := effortWireServer(t) + defer srv.Close() + previous, err := m.pg.ActiveProfile() + if err != nil { + t.Fatal(err) + } + profile := &db.LLMProfile{ + Name: fmt.Sprintf("invalid-save-%d", time.Now().UnixNano()), Format: "openai-responses", + BaseURL: srv.URL, Model: "profile-model", APIKey: "test-key", RatePerMinute: 1, Priority: 10, + ThinkingType: "enabled", ReasoningEffort: "high", Streaming: true, + } + id, err := m.pg.SaveProfile(profile) + if err != nil { + t.Fatal(err) + } + defer func() { + if previous != nil { + _ = m.pg.SetActiveProfile(previous.ID) + } else { + _, _ = m.pg.Exec(`UPDATE llm_profiles SET is_default=false WHERE id=$1`, id) + } + _ = m.pg.DeleteProfile(id) + }() + if err := m.pg.SetActiveProfile(id); err != nil { + t.Fatal(err) + } + poolEnabled := m.LLMPoolEnabled() + if err := m.SetLLMPoolEnabled(false); err != nil { + t.Fatal(err) + } + defer func() { _ = m.SetLLMPoolEnabled(poolEnabled) }() + s := &Server{m: m, llmHealth: newLLMHealthRegistry(m.pg)} + cfg, ok := s.loadLLMConfig() + if !ok { + t.Fatal("active profile unavailable") + } + if err := s.applyLLM(cfg); err != nil { + t.Fatal(err) + } + maxProvider, _, ok := s.globalProviderForEffort(ThinkingEffortMax) + if !ok { + t.Fatal("max effort unavailable") + } + assertEffortWire(t, effortWireRequest(t, maxProvider, requests), cfg.Model, cfg.ReasoningEffort) + checkAutoBlocked := func(stage string) { + t.Helper() + autoProvider, _, ok := s.globalProviderForEffort(ThinkingEffortAuto) + if !ok { + t.Fatalf("%s: auto effort unavailable", stage) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + _, _, _, err := autoProvider.Complete(ctx, llm.CompletionRequest{}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("%s: auto effort bypassed running limiter: %v", stage, err) + } + select { + case request := <-requests: + t.Fatalf("%s: auto effort sent a second request before rate limit: %v", stage, request) + default: + } + } + profile.Name = "renamed-" + profile.Name + update, err := json.Marshal(map[string]any{ + "id": id, "name": profile.Name, "format": profile.Format, "base_url": profile.BaseURL, + "model": profile.Model, "proxy": "", "rate_per_minute": 1, "streaming": true, "priority": profile.Priority, + "thinking_type": profile.ThinkingType, "reasoning_effort": profile.ReasoningEffort, + }) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + s.pgSaveProfile(recorder, httptest.NewRequest(http.MethodPost, "/api/llm/profiles", bytes.NewReader(update))) + if recorder.Code != http.StatusOK { + t.Fatalf("profile rename: %d %s", recorder.Code, recorder.Body.String()) + } + checkAutoBlocked("renamed profile") + draftName := "draft-" + profile.Name + update, err = json.Marshal(map[string]any{ + "id": id, "name": draftName, "format": profile.Format, "base_url": profile.BaseURL, + "model": "draft-model", "proxy": "://invalid", "rate_per_minute": 1, "streaming": true, "priority": 0, + "thinking_type": profile.ThinkingType, "reasoning_effort": profile.ReasoningEffort, + }) + if err != nil { + t.Fatal(err) + } + recorder = httptest.NewRecorder() + s.pgSaveProfile(recorder, httptest.NewRequest(http.MethodPost, "/api/llm/profiles", bytes.NewReader(update))) + if recorder.Code != http.StatusOK { + t.Fatalf("invalid profile save: %d %s", recorder.Code, recorder.Body.String()) + } + saved, err := m.pg.ProfileByID(id) + if err != nil || saved == nil || saved.Proxy != "://invalid" || saved.Name != draftName || saved.Model != "draft-model" { + t.Fatalf("draft proxy missing: profile = %+v, error = %v", saved, err) + } + s.reapplyActiveProfile() + if s.llmCfg.Proxy != "" { + t.Fatalf("invalid proxy replaced running config: %q", s.llmCfg.Proxy) + } + global, err := s.resolveTaskRoleLLM(&Task{}, "unbound-resolution-role") + if err != nil || !global.Available || global.ProfileID == nil || *global.ProfileID != id || global.Name != profile.Name || global.Model != profile.Model { + t.Fatalf("global resolution reported draft: %+v, error = %v", global, err) + } + chainTask := &Task{LLMProfileIDs: []int64{id}, ActiveLLMProfileID: &id} + chain, err := s.resolveTaskRoleLLM(chainTask, "unbound-resolution-role") + if err != nil || !chain.Available || chain.Name != profile.Name || chain.Model != profile.Model { + t.Fatalf("task chain reported draft: %+v, error = %v", chain, err) + } + if err := s.rebuildCurrentLLM(); err != nil { + t.Fatal(err) + } + global, err = s.resolveTaskRoleLLM(&Task{}, "unbound-resolution-role") + if err != nil || global.Name != profile.Name || global.Model != profile.Model { + t.Fatalf("settings rebuild reported draft: %+v, error = %v", global, err) + } + s.resetGlobalAutoProvider() + queuedAuto, queuedCfg, ok := s.providerForProfileEffort(id, ThinkingEffortAuto) + if !ok || queuedCfg.Model != profile.Model { + t.Fatalf("queued auto resolved draft: config = %+v, available = %v", queuedCfg, ok) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + _, _, _, err = queuedAuto.Complete(ctx, llm.CompletionRequest{}) + cancel() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("queued auto bypassed running limiter: %v", err) + } + select { + case request := <-requests: + t.Fatalf("queued auto sent a second request before rate limit: %v", request) + default: + } + checkAutoBlocked("invalid draft") + previousPolicy := m.pg.LLMRetryPolicy() + defer func() { _ = m.pg.SetLLMRetryPolicy(previousPolicy) }() + fallbackName := fmt.Sprintf("retry-fallback-%d", time.Now().UnixNano()) + fallbackID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fallbackName, Format: "openai-responses", + BaseURL: srv.URL, Model: "fallback-model", APIKey: "test-key", Streaming: true, Priority: 5, + }) + if err != nil { + t.Fatal(err) + } + defer func() { _ = m.pg.DeleteProfile(fallbackID) }() + if _, _, ok := s.providerForProfileEffort(fallbackID, ""); !ok { + t.Fatal("fallback profile unavailable") + } + policy, err := json.Marshal(db.LLMRetryPolicy{Connect: db.RetryRule{Attempts: 1}}) + if err != nil { + t.Fatal(err) + } + recorder = httptest.NewRecorder() + s.pgSaveLLMRetryPolicy(recorder, httptest.NewRequest(http.MethodPost, "/api/llm/retry-policy", bytes.NewReader(policy))) + if recorder.Code != http.StatusOK { + t.Fatalf("retry policy save: %d %s", recorder.Code, recorder.Body.String()) + } + if _, _, ok := s.providerForProfileEffort(id, ""); !ok { + t.Fatal("running profile unavailable after retry policy save") + } + _, fallbackCfg, ok := s.providerForProfileEffort(fallbackID, "") + if !ok || fallbackCfg.Retry.ConnectAttempts != 1 { + t.Fatalf("fallback profile retry config = %+v, available = %v", fallbackCfg.Retry, ok) + } + checkAutoBlocked("retry policy") + update, err = json.Marshal(map[string]any{ + "id": fallbackID, "name": "draft-" + fallbackName, "format": "openai-responses", "base_url": srv.URL, + "model": "draft-fallback", "proxy": "://invalid", "streaming": true, "priority": 0, "pool_exclude": true, + }) + if err != nil { + t.Fatal(err) + } + recorder = httptest.NewRecorder() + s.pgSaveProfile(recorder, httptest.NewRequest(http.MethodPost, "/api/llm/profiles", bytes.NewReader(update))) + if recorder.Code != http.StatusOK { + t.Fatalf("invalid fallback save: %d %s", recorder.Code, recorder.Body.String()) + } + otherID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("other-fallback-%d", time.Now().UnixNano()), Format: "openai-responses", + BaseURL: srv.URL, Model: "other-model", APIKey: "test-key", Streaming: true, Priority: 2, + }) + if err != nil { + t.Fatal(err) + } + defer func() { _ = m.pg.DeleteProfile(otherID) }() + excludedID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("excluded-fallback-%d", time.Now().UnixNano()), Format: "openai-responses", + BaseURL: srv.URL, Model: "excluded-model", APIKey: "test-key", Streaming: true, Priority: 20, PoolExclude: true, + }) + if err != nil { + t.Fatal(err) + } + defer func() { _ = m.pg.DeleteProfile(excludedID) }() + if _, _, ok := s.providerForProfileEffort(excludedID, ""); !ok { + t.Fatal("excluded profile unavailable to explicit binding") + } + update, err = json.Marshal(map[string]any{ + "id": excludedID, "name": "draft-excluded", "format": "openai-responses", "base_url": srv.URL, + "model": "draft-excluded", "proxy": "://invalid", "streaming": true, "priority": 20, "pool_exclude": false, + }) + if err != nil { + t.Fatal(err) + } + recorder = httptest.NewRecorder() + s.pgSaveProfile(recorder, httptest.NewRequest(http.MethodPost, "/api/llm/profiles", bytes.NewReader(update))) + if recorder.Code != http.StatusOK { + t.Fatalf("invalid excluded profile save: %d %s", recorder.Code, recorder.Body.String()) + } + t.Setenv("JCYBER_LLM_ROUND_ROBIN", "1") + if err := m.SetLLMPoolEnabled(true); err != nil { + t.Fatal(err) + } + if err := s.rebuildCurrentLLM(); err != nil { + t.Fatal(err) + } + s.resetGlobalAutoProvider() + for _, effort := range []string{ThinkingEffortMax, ThinkingEffortAuto} { + prov, _, ok := s.globalProviderForEffort(effort) + pool, poolOK := prov.(*llmpool.Pool) + if !ok || !poolOK { + t.Fatalf("%s effort has no failover pool: available = %v, provider = %T", effort, ok, prov) + } + head := pool.Head() + if head.Name != profile.Name || head.Model != profile.Model || head.Format != profile.Format || head.Priority != profile.Priority || head.Rank != profile.Priority { + t.Fatalf("%s effort used draft pool metadata: %+v", effort, head) + } + var fallback *llmpool.Member + fallbackIndex, otherIndex := -1, -1 + for i, member := range pool.Members() { + if member.ID == fallbackID { + fallback = member + fallbackIndex = i + } else if member.ID == otherID { + otherIndex = i + } else if member.ID == excludedID { + t.Fatalf("%s effort admitted applied-excluded profile: %+v", effort, member) + } + } + if fallback == nil || fallback.Name != fallbackName || fallback.Model != "fallback-model" || fallback.Format != "openai-responses" || fallback.Priority != 5 || fallback.Rank != 5 { + t.Fatalf("%s effort used draft fallback metadata: %+v", effort, fallback) + } + if otherIndex < 0 || fallbackIndex >= otherIndex { + t.Fatalf("%s effort ordered draft priority ahead of applied priority: fallback index %d, other index %d", effort, fallbackIndex, otherIndex) + } + } + status, ok := s.llmPoolStatus()["chain"].([]LLMPoolMemberStatus) + if !ok { + t.Fatal("pool status missing chain") + } + foundIncluded, foundExcluded := false, false + for _, member := range status { + switch member.ProfileID { + case i64s(fallbackID): + foundIncluded = true + if member.Excluded || member.Priority != 5 || member.Name != fallbackName { + t.Fatalf("pool status used included profile draft: %+v", member) + } + case i64s(excludedID): + foundExcluded = true + if !member.Excluded || member.Model != "excluded-model" { + t.Fatalf("pool status used excluded profile draft: %+v", member) + } + } + } + if !foundIncluded || !foundExcluded { + t.Fatalf("pool status omitted profiles: included=%v excluded=%v", foundIncluded, foundExcluded) + } +} + +func TestKeylessActivationSettingsRebuildsAppliedChat(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + srv, requests := effortWireServer(t) + defer srv.Close() + previous, err := m.pg.ActiveProfile() + if err != nil { + t.Fatal(err) + } + stamp := time.Now().UnixNano() + appliedID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("settings-applied-%d", stamp), Format: "openai-responses", + BaseURL: srv.URL, Model: "settings-model", APIKey: "test-key", Streaming: true, + }) + if err != nil { + t.Fatal(err) + } + var keylessID int64 + defer func() { + if previous != nil { + _ = m.pg.SetActiveProfile(previous.ID) + } else { + _, _ = m.pg.Exec(`UPDATE llm_profiles SET is_default=false WHERE id=$1 OR id=$2`, appliedID, keylessID) + } + _ = m.pg.DeleteProfile(appliedID) + if keylessID != 0 { + _ = m.pg.DeleteProfile(keylessID) + } + }() + keylessID, err = m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("settings-keyless-%d", stamp), Format: "openai-responses", + BaseURL: srv.URL, Model: "pending-model", Streaming: true, + }) + if err != nil { + t.Fatal(err) + } + poolEnabled := m.LLMPoolEnabled() + if err := m.SetLLMPoolEnabled(false); err != nil { + t.Fatal(err) + } + defer func() { _ = m.SetLLMPoolEnabled(poolEnabled) }() + priorOn, priorBackend, priorBrave, priorTavily, priorProxy := m.WebSearch() + if err := m.SetWebSearch(true, "ddgs", nil, nil, nil); err != nil { + t.Fatal(err) + } + defer func() { _ = m.SetWebSearch(priorOn, priorBackend, &priorBrave, &priorTavily, &priorProxy) }() + if err := m.pg.SetActiveProfile(appliedID); err != nil { + t.Fatal(err) + } + s := &Server{m: m, llmHealth: newLLMHealthRegistry(m.pg)} + cfg, ok := s.loadLLMConfig() + if !ok { + t.Fatal("applied profile unavailable") + } + if err := s.applyLLM(cfg); err != nil { + t.Fatal(err) + } + if err := m.pg.SetActiveProfile(keylessID); err != nil { + t.Fatal(err) + } + s.reapplyActiveProfile() + chatRequest := func(sessionID string) map[string]any { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, _ = s.chatAgentRef().Chat(ctx, "settings-test-agent", sessionID, "hello", 1, 0, true, nil) + select { + case body := <-requests: + return body + case <-ctx.Done(): + t.Fatal("chat did not send provider request") + return nil + } + } + hasSearch := func(body map[string]any) bool { + tools, _ := body["tools"].([]any) + for _, raw := range tools { + tool, _ := raw.(map[string]any) + if tool["name"] == "web_search" { + return true + } + } + return false + } + if !hasSearch(chatRequest("search-before")) { + t.Fatal("web search missing before settings change") + } + recorder := httptest.NewRecorder() + s.putSettings(recorder, httptest.NewRequest(http.MethodPut, "/api/settings", bytes.NewBufferString(`{"web_search_enabled":false}`))) + if recorder.Code != http.StatusOK { + t.Fatalf("settings update: %d %s", recorder.Code, recorder.Body.String()) + } + if s.llmProfileID != appliedID || s.llmCfg.Model != "settings-model" { + t.Fatalf("settings rebuild changed applied profile: id=%d model=%s", s.llmProfileID, s.llmCfg.Model) + } + if hasSearch(chatRequest("search-after")) { + t.Fatal("web search remains enabled after settings change") + } +} + +func TestGlobalAutoEffortDoesNotAttributeOldConfigToNewActiveProfile(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + requests := make(chan map[string]any, 4) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var data map[string]any + if json.Unmarshal(body, &data) == nil { + requests <- data + } + w.WriteHeader(http.StatusPaymentRequired) + })) + defer srv.Close() + previous, err := m.pg.ActiveProfile() + if err != nil { + t.Fatal(err) + } + stamp := time.Now().UnixNano() + oldID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("effort-old-active-%d", stamp), Format: "openai-responses", + BaseURL: srv.URL, Model: "old-model", APIKey: "test-key", ReasoningEffort: "high", RatePerMinute: 1, + }) + if err != nil { + t.Fatal(err) + } + newID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("effort-new-active-%d", stamp), Format: "openai-responses", + BaseURL: srv.URL, Model: "new-model", ReasoningEffort: "high", + }) + if err != nil { + t.Fatal(err) + } + fallbackServer, fallbackRequests := effortWireServer(t) + defer fallbackServer.Close() + fallbackID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("effort-fallback-%d", stamp), Format: "openai-responses", + BaseURL: fallbackServer.URL, Model: "fallback-model", APIKey: "test-key", Priority: 1000000, + }) + if err != nil { + t.Fatal(err) + } + defer func() { + if previous != nil { + _ = m.pg.SetActiveProfile(previous.ID) + } else { + _, _ = m.pg.Exec(`UPDATE llm_profiles SET is_default=false WHERE id=$1`, newID) + } + _ = m.pg.DeleteProfile(oldID) + _ = m.pg.DeleteProfile(newID) + _ = m.pg.DeleteProfile(fallbackID) + }() + if err := m.pg.SetActiveProfile(oldID); err != nil { + t.Fatal(err) + } + poolEnabled := m.LLMPoolEnabled() + if err := m.SetLLMPoolEnabled(true); err != nil { + t.Fatal(err) + } + defer func() { _ = m.SetLLMPoolEnabled(poolEnabled) }() + s := &Server{m: m, llmHealth: newLLMHealthRegistry(m.pg)} + cfg, ok := s.loadLLMConfig() + if !ok { + t.Fatal("old active profile unavailable") + } + if err := s.applyLLM(cfg); err != nil { + t.Fatal(err) + } + if err := m.pg.SetActiveProfile(newID); err != nil { + t.Fatal(err) + } + s.resetGlobalAutoProvider() + s.invalidateProfileChatAgents() + s.invalidateTaskAgents() + provider, autoCfg, ok := s.globalProviderForEffort(ThinkingEffortAuto) + if !ok || autoCfg.Model != "old-model" { + t.Fatalf("auto config = %+v, available = %v; want old applied model", autoCfg, ok) + } + assertEffortWire(t, effortWireRequest(t, provider, requests), "old-model", ThinkingEffortAuto) + select { + case request := <-fallbackRequests: + assertEffortWire(t, request, "fallback-model", ThinkingEffortAuto) + case <-time.After(3 * time.Second): + t.Fatal("auto effort did not fail over to fallback profile") + } + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + _, _, _, err = s.llmDirect.Complete(ctx, llm.CompletionRequest{}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("applied profile's limiter did not include auto request: %v", err) + } + select { + case request := <-requests: + t.Fatalf("applied profile sent a second request before rate limit: %v", request) + default: + } + if state, recorded := s.llmHealth.Snapshot()[newID]; recorded { + t.Fatalf("new active profile got old provider's failure: %+v", state) + } + updated, err := json.Marshal(map[string]any{ + "id": oldID, "name": fmt.Sprintf("effort-old-active-%d", stamp), "format": "openai-responses", + "base_url": srv.URL, "model": "edited-model", "rate_per_minute": 1, + "reasoning_effort": "high", "streaming": false, + }) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + s.pgSaveProfile(recorder, httptest.NewRequest(http.MethodPost, "/api/llm/profiles", bytes.NewReader(updated))) + if recorder.Code != http.StatusOK || s.llmCfg.Model != "edited-model" || s.llmProfileID != oldID { + t.Fatalf("applied profile edit stayed stale: status=%d config=%s profile=%d", recorder.Code, s.llmCfg.Model, s.llmProfileID) + } + _, boundCfg, ok := s.providerForProfileEffort(oldID, "") + if !ok || boundCfg.Model != "edited-model" { + t.Fatalf("bound profile edit stayed stale: config=%+v available=%v", boundCfg, ok) + } +} + +func TestApplyLLMInvalidatesStaleGlobalAutoProvider(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + srv, requests := effortWireServer(t) + defer srv.Close() + previous, err := m.pg.ActiveProfile() + if err != nil { + t.Fatal(err) + } + profile := &db.LLMProfile{ + Name: fmt.Sprintf("effort-update-%d", time.Now().UnixNano()), Format: "openai-responses", + BaseURL: srv.URL, Model: "old-model", APIKey: "test-key", ReasoningEffort: "high", Streaming: true, + } + id, err := m.pg.SaveProfile(profile) + if err != nil { + t.Fatal(err) + } + defer func() { + if previous != nil { + _ = m.pg.SetActiveProfile(previous.ID) + } else { + _, _ = m.pg.Exec(`UPDATE llm_profiles SET is_default=false WHERE id=$1`, id) + } + _ = m.pg.DeleteProfile(id) + }() + if err := m.pg.SetActiveProfile(id); err != nil { + t.Fatal(err) + } + poolEnabled := m.LLMPoolEnabled() + if err := m.SetLLMPoolEnabled(false); err != nil { + t.Fatal(err) + } + defer func() { _ = m.SetLLMPoolEnabled(poolEnabled) }() + s := &Server{m: m, llmHealth: newLLMHealthRegistry(m.pg)} + oldConfig, ok := s.loadLLMConfig() + if !ok { + t.Fatal("old profile unavailable") + } + if err := s.applyLLM(oldConfig); err != nil { + t.Fatal(err) + } + profile.ID, profile.Model = id, "new-model" + if _, err := m.pg.SaveProfile(profile); err != nil { + t.Fatal(err) + } + s.invalidateProfileProvider(id) + _, staleConfig, ok := s.globalProviderForEffort(ThinkingEffortAuto) + if !ok || staleConfig.Model != "old-model" { + t.Fatalf("pre-reapply auto config = %+v, available = %v; want old applied model", staleConfig, ok) + } + newConfig, ok := s.loadLLMConfig() + if !ok { + t.Fatal("new profile unavailable") + } + if err := s.applyLLM(newConfig); err != nil { + t.Fatal(err) + } + provider, cfg, ok := s.globalProviderForEffort(ThinkingEffortAuto) + if !ok || cfg.Model != "new-model" { + t.Fatalf("post-reapply auto config = %+v, available = %v; want new applied model", cfg, ok) + } + assertEffortWire(t, effortWireRequest(t, provider, requests), "new-model", ThinkingEffortAuto) +} + +func TestSideAutoEffortUsesCapturedProviderWhenPoolFailsOver(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + activeServer, activeRequests := effortWireServer(t) + defer activeServer.Close() + fallbackServer, fallbackRequests := effortWireServer(t) + defer fallbackServer.Close() + previous, err := m.pg.ActiveProfile() + if err != nil { + t.Fatal(err) + } + stamp := time.Now().UnixNano() + activeID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("side-auto-active-%d", stamp), Format: "openai-responses", + BaseURL: activeServer.URL, Model: "active-model", APIKey: "test-key", ReasoningEffort: "high", + }) + if err != nil { + t.Fatal(err) + } + fallbackID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("side-auto-fallback-%d", stamp), Format: "openai-responses", + BaseURL: fallbackServer.URL, Model: "fallback-model", APIKey: "test-key", ReasoningEffort: "high", + }) + if err != nil { + t.Fatal(err) + } + defer func() { + if previous != nil { + _ = m.pg.SetActiveProfile(previous.ID) + } else { + _, _ = m.pg.Exec(`UPDATE llm_profiles SET is_default=false WHERE id=$1`, activeID) + } + _ = m.pg.DeleteProfile(activeID) + _ = m.pg.DeleteProfile(fallbackID) + }() + if err := m.pg.SetActiveProfile(activeID); err != nil { + t.Fatal(err) + } + poolEnabled := m.LLMPoolEnabled() + if err := m.SetLLMPoolEnabled(true); err != nil { + t.Fatal(err) + } + defer func() { _ = m.SetLLMPoolEnabled(poolEnabled) }() + s := &Server{m: m, llmHealth: newLLMHealthRegistry(m.pg)} + cfg, ok := s.loadLLMConfig() + if !ok { + t.Fatal("active profile unavailable") + } + if err := s.applyLLM(cfg); err != nil { + t.Fatal(err) + } + model := sideModel(resolvedEffortConfig(cfg, ThinkingEffortAuto), 0, "active") + s.llmHealth.Trip(activeID, "quota exhausted", true) + provider, err := s.sideProvider(model) + if err != nil { + t.Fatal(err) + } + assertEffortWire(t, effortWireRequest(t, provider, activeRequests), cfg.Model, ThinkingEffortAuto) + select { + case body := <-fallbackRequests: + t.Fatalf("side question used fallback profile: %v", body) + default: + } +} + +func TestTaskEffortRoutingAcrossBindings(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + srv, requests := effortWireServer(t) + defer srv.Close() + stamp := time.Now().UnixNano() + boundID, err := m.pg.SaveProfile(&db.LLMProfile{Name: fmt.Sprintf("effort-bound-%d", stamp), Format: "openai-responses", BaseURL: srv.URL, Model: "bound-model", APIKey: "test-key", ReasoningEffort: "high"}) + if err != nil { + t.Fatal(err) + } + chainID, err := m.pg.SaveProfile(&db.LLMProfile{Name: fmt.Sprintf("effort-chain-%d", stamp), Format: "openai-responses", BaseURL: srv.URL, Model: "chain-model", APIKey: "test-key", ReasoningEffort: "high"}) + if err != nil { + t.Fatal(err) + } + prior, err := m.pg.GetAgentByKey("worker") + if err != nil { + t.Fatal(err) + } + var priorID *int64 + if prior != nil { + priorID = prior.LLMProfileID + } + var taskIDs []string + defer func() { + _ = m.pg.SetAgentLLMProfile("worker", priorID) + for _, id := range taskIDs { + _, _ = m.DeleteTask(id, DeleteTaskOptions{}) + } + _ = m.pg.DeleteProfile(boundID) + _ = m.pg.DeleteProfile(chainID) + }() + autoTask, err := m.CreateTaskWithOptions("auto effort", "use binding", db.TaskCreateOptions{LLMProfileIDs: []int64{chainID}, ThinkingEffort: ThinkingEffortAuto}) + if err != nil { + t.Fatal(err) + } + taskIDs = append(taskIDs, autoTask.ID) + if err := m.pg.SetAgentLLMProfile("worker", &boundID); err != nil { + t.Fatal(err) + } + s := &Server{m: m, llmHealth: newLLMHealthRegistry(m.pg)} + selection, err := (&taskLLMRuntime{s: s, taskID: autoTask.ID, agentKey: "worker"}).current() + if err != nil { + t.Fatal(err) + } + assertEffortWire(t, effortWireRequest(t, selection.provider, requests), "bound-model", ThinkingEffortAuto) + if err := m.pg.SetAgentLLMProfile("worker", nil); err != nil { + t.Fatal(err) + } + maxTask, err := m.CreateTaskWithOptions("max effort", "use chain", db.TaskCreateOptions{LLMProfileIDs: []int64{chainID}, ThinkingEffort: ThinkingEffortMax}) + if err != nil { + t.Fatal(err) + } + taskIDs = append(taskIDs, maxTask.ID) + selection, err = (&taskLLMRuntime{s: s, taskID: maxTask.ID, agentKey: "worker"}).current() + if err != nil { + t.Fatal(err) + } + defaultProvider, _, ok := s.providerForProfile(chainID) + if !ok || selection.provider != defaultProvider { + t.Fatal("max task did not reuse the selected profile provider") + } + assertEffortWire(t, effortWireRequest(t, selection.provider, requests), "chain-model", "high") +} diff --git a/server/task_llm.go b/server/task_llm.go index 08edb21..1d1572e 100644 --- a/server/task_llm.go +++ b/server/task_llm.go @@ -12,11 +12,11 @@ import ( "strings" "time" + "normasdk/llm" + "normasdk/transcript" "pta/agent" "pta/db" "pta/llmrec" - "normasdk/llm" - "normasdk/transcript" ) type taskAgentBundle struct { @@ -122,7 +122,7 @@ func (r *taskLLMRuntime) current() (taskLLMSelection, error) { r.s.syncTaskLLMState(pt) t, _ := r.s.m.Task(r.taskID) sel := taskLLMSelection{task: t, revision: pt.LLMChainRevision} - if prov, cfg, ok := r.s.agentBindingProvider(r.agentKey); ok { + if prov, cfg, ok := r.s.agentBindingProviderEffort(r.agentKey, pt.ThinkingEffort); ok { sel.provider, sel.retry = prov, cfg.Retry return sel, nil } @@ -131,14 +131,14 @@ func (r *taskLLMRuntime) current() (taskLLMSelection, error) { return sel, &taskLLMError{taskID: r.taskID, chainExhausted: true, cause: errors.New("all selected profiles are quota exhausted")} } sel.profileID = *pt.ActiveLLMProfileID - prov, cfg, ok := r.s.providerForProfile(sel.profileID) + prov, cfg, ok := r.s.providerForProfileEffort(sel.profileID, pt.ThinkingEffort) if !ok { return sel, fmt.Errorf("LLM profile #%d is missing or invalid", sel.profileID) } sel.provider, sel.retry = prov, cfg.Retry return sel, nil } - prov, cfg, ok := r.s.globalProvider() + prov, cfg, ok := r.s.globalProviderForEffort(pt.ThinkingEffort) if !ok { return sel, fmt.Errorf("task %s has no available fallback LLM provider", r.taskID) } @@ -160,15 +160,15 @@ func (r *taskLLMRuntime) activeCfg() (agent.Config, bool) { if err != nil || pt == nil { return agent.Config{}, false } - if _, cfg, ok := r.s.agentBindingProvider(r.agentKey); ok { + if _, cfg, ok := r.s.agentBindingProviderEffort(r.agentKey, pt.ThinkingEffort); ok { return cfg, true } if len(pt.LLMProfileIDs) > 0 && pt.ActiveLLMProfileID != nil { - if _, cfg, ok := r.s.providerForProfile(*pt.ActiveLLMProfileID); ok { + if _, cfg, ok := r.s.providerForProfileEffort(*pt.ActiveLLMProfileID, pt.ThinkingEffort); ok { return cfg, true } } - if _, cfg, ok := r.s.globalProvider(); ok { + if _, cfg, ok := r.s.globalProviderForEffort(pt.ThinkingEffort); ok { return cfg, true } return agent.Config{}, false @@ -454,26 +454,30 @@ func isRetryableStreamError(err error) 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() } + pt, err := r.s.m.pg.GetTask(taskNum) + if err != nil || pt == nil { + return (agent.Config{}).CompactionWindow() + } + if _, cfg, ok := r.s.agentBindingProviderEffort(r.agentKey, pt.ThinkingEffort); ok { + return cfg.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 { + if _, cfg, ok := r.s.globalProviderForEffort(pt.ThinkingEffort); ok { return cfg.CompactionWindow() } return (agent.Config{}).CompactionWindow() } minimum := 0 for _, entry := range chain { - if cfg, ok := r.s.loadProfileConfig(entry.ProfileID); ok { + if _, cfg, ok := r.s.providerForProfileEffort(entry.ProfileID, pt.ThinkingEffort); ok { window := cfg.CompactionWindow() if minimum == 0 || window < minimum { minimum = window @@ -492,15 +496,21 @@ func (r *taskLLMRuntime) CompactionWindow() int { // 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) { + return s.agentBindingProviderEffort(agentKey, "") +} + +func (s *Server) agentBindingProviderEffort(agentKey, effort string) (llm.Provider, agent.Config, bool) { + s.profileReapplyMu.RLock() + defer s.profileReapplyMu.RUnlock() id := s.effectiveProfileForAgent(agentKey, nil) if id == nil { return nil, agent.Config{}, false } - prov, cfg, ok := s.providerForProfile(*id) + prov, cfg, ok := s.providerForProfileEffortUnlocked(*id, effort) if !ok { return nil, agent.Config{}, false } - return s.poolForBinding(*id, prov, cfg), cfg, true + return s.poolForBindingEffortLocked(*id, prov, cfg, effort), cfg, true } // globalProvider returns the process-wide provider (persisted active profile or @@ -527,13 +537,13 @@ func (s *Server) taskRuntimeAvailable(t *Task, agentKeys ...string) bool { unboundReady := false if len(state.ProfileIDs) > 0 { if state.ActiveID != nil { - _, _, unboundReady = s.providerForProfile(*state.ActiveID) + _, _, unboundReady = s.providerForProfileEffort(*state.ActiveID, t.ThinkingEffort) } } else { - _, _, unboundReady = s.globalProvider() + _, _, unboundReady = s.globalProviderForEffort(t.ThinkingEffort) } for _, key := range agentKeys { - if _, _, ok := s.agentBindingProvider(key); ok { + if _, _, ok := s.agentBindingProviderEffort(key, t.ThinkingEffort); ok { continue } if !unboundReady { diff --git a/server/task_llm_test.go b/server/task_llm_test.go index 13be46d..fdea4cb 100644 --- a/server/task_llm_test.go +++ b/server/task_llm_test.go @@ -11,10 +11,10 @@ import ( "testing" "time" - "pta/agent" - "pta/db" "normasdk/harness" "normasdk/llm" + "pta/agent" + "pta/db" ) type scriptedLLMProvider struct { @@ -54,6 +54,59 @@ func accumulateStreamForTest(ctx context.Context, stream func(context.Context, l return acc.Message(), acc.StopReason, acc.Usage, nil } +func TestTaskCompactionWindowUsesAppliedAutoChain(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() + firstID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("window-first-%d", stamp), Format: "openai-responses", + Model: "first-model", APIKey: "test-key", Streaming: true, ContextWindowK: 64, + }) + if err != nil { + t.Fatal(err) + } + secondID, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: fmt.Sprintf("window-second-%d", stamp), Format: "openai-responses", + Model: "second-model", APIKey: "test-key", Streaming: true, ContextWindowK: 32, + }) + if err != nil { + _ = m.pg.DeleteProfile(firstID) + t.Fatal(err) + } + task, err := m.CreateTaskWithOptions("auto compaction", "keep applied window", db.TaskCreateOptions{ + LLMProfileIDs: []int64{firstID, secondID}, ThinkingEffort: ThinkingEffortAuto, + }) + if err != nil { + _ = m.pg.DeleteProfile(firstID) + _ = m.pg.DeleteProfile(secondID) + t.Fatal(err) + } + defer func() { + _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) + _ = m.pg.DeleteProfile(firstID) + _ = m.pg.DeleteProfile(secondID) + }() + + s := &Server{m: m} + for _, id := range []int64{firstID, secondID} { + if _, _, ok := s.providerForProfileEffort(id, ThinkingEffortAuto); !ok { + t.Fatalf("applied auto provider %d unavailable", id) + } + } + if _, err := m.pg.Exec(`UPDATE llm_profiles SET context_window_k=1000, proxy='://invalid' WHERE id=$1`, secondID); err != nil { + t.Fatal(err) + } + runtime := &taskLLMRuntime{s: s, taskID: task.ID, agentKey: "unbound-window-role"} + want := (agent.Config{ContextWindowK: 32}).CompactionWindow() + if got := runtime.CompactionWindow(); got != want { + t.Fatalf("compaction window = %d, want applied chain minimum %d", got, want) + } +} + // withZeroRetryBackoff sets the same-provider retry backoff to zero for the // duration of a (serial) test and returns a restore func for defer. func withZeroRetryBackoff() func() { diff --git a/server/task_metadata_test.go b/server/task_metadata_test.go index 2ef209a..96d1405 100644 --- a/server/task_metadata_test.go +++ b/server/task_metadata_test.go @@ -60,6 +60,73 @@ func TestTaskMetadataPatchReturnsRenameAndPin(t *testing.T) { } } +func TestTaskRename(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer m.Close() + task, err := m.CreateTask("original description", "original goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + taskID, _ := strconv.ParseInt(task.ID, 10, 64) + defer func() { _ = m.pg.DeleteTask(taskID) }() + s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir()) + token, err := signJWT(s.jwtKey) + if err != nil { + t.Fatal(err) + } + handler := s.Handler() + + for _, test := range []struct { + label string + id string + name string + status int + }{ + {"trimmed name persists", task.ID, " \tRenamed task\n ", http.StatusOK}, + {"empty name rejected", task.ID, "", http.StatusBadRequest}, + {"whitespace name rejected", task.ID, " \t\n ", http.StatusBadRequest}, + {"unknown task rejected", "4611686018427387903", "Missing task", http.StatusNotFound}, + } { + t.Run(test.label, func(t *testing.T) { + body, err := json.Marshal(map[string]string{"name": test.name}) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPatch, "/api/tasks/"+test.id, bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != test.status { + t.Fatalf("rename status=%d want=%d body=%s", rec.Code, test.status, rec.Body.String()) + } + if test.status == http.StatusOK { + var updated struct { + Name string `json:"name"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil { + t.Fatal(err) + } + if updated.Name != "Renamed task" { + t.Fatalf("rename response name=%q", updated.Name) + } + } + persisted, err := m.pg.GetTask(taskID) + if err != nil { + t.Fatal(err) + } + if persisted == nil || persisted.Name != "Renamed task" { + t.Fatalf("rename did not persist or invalid request changed name: %+v", persisted) + } + if persisted.Description != "original description" || persisted.Goal != "original goal" { + t.Fatalf("rename changed description or goal: %+v", persisted) + } + }) + } +} + func TestConversationBatchDeleteReportsMissing(t *testing.T) { m, err := NewManager(t.TempDir(), "") if err != nil { diff --git a/server/task_resolution.go b/server/task_resolution.go index ede7339..ec07931 100644 --- a/server/task_resolution.go +++ b/server/task_resolution.go @@ -17,7 +17,7 @@ type taskLLMResolution struct { Reason string `json:"reason,omitempty"` } -func (s *Server) resolutionFromProfile(p *db.LLMProfile, source string) taskLLMResolution { +func (s *Server) resolutionFromProfile(p *db.LLMProfile, source, effort string) taskLLMResolution { if p == nil { return taskLLMResolution{Source: source, Reason: "LLM profile does not exist"} } @@ -29,14 +29,23 @@ func (s *Server) resolutionFromProfile(p *db.LLMProfile, source string) taskLLMR Model: p.Model, Source: source, } - if p.APIKey == "" { - result.Reason = "LLM profile has no API key" - return result + s.profileReapplyMu.RLock() + _, cfg, ok := s.providerForProfileEffortUnlocked(p.ID, effort) + if ok { + if cached := s.cachedProfileEntry(p.ID, effort); cached != nil && cached.name != "" { + result.Name = cached.name + } } - if _, _, ok := s.providerForProfile(p.ID); !ok { - result.Reason = "Invalid or unsupported LLM profile format" + s.profileReapplyMu.RUnlock() + if !ok { + if p.APIKey == "" { + result.Reason = "LLM profile has no API key" + } else { + result.Reason = "Invalid or unsupported LLM profile format" + } return result } + result.Format, result.Model = cfg.Provider(), cfg.Model result.Available = true return result } @@ -58,7 +67,7 @@ func (s *Server) resolveTaskRoleLLM(t *Task, agentKey string) (taskLLMResolution return taskLLMResolution{}, fmt.Errorf("load agent LLM profile: %w", err) } if p != nil { - resolved := s.resolutionFromProfile(p, "agent_binding") + resolved := s.resolutionFromProfile(p, "agent_binding", t.ThinkingEffort) if resolved.Available { return resolved, nil } @@ -73,29 +82,24 @@ func (s *Server) resolveTaskRoleLLM(t *Task, agentKey string) (taskLLMResolution 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) - } - if p != nil { - resolved := s.resolutionFromProfile(p, "global_profile") - if resolved.Available { - return resolved, nil - } + return s.resolutionFromProfile(p, "task_chain", t.ThinkingEffort), nil } s.cfgMu.Lock() - on, name, cfg := s.llmOn, s.llmProf, s.llmCfg + on, name, cfg, appliedID := s.llmOn, s.llmProf, s.llmCfg, s.llmProfileID s.cfgMu.Unlock() if on { if name == "" { name = "Global Profile" } - return taskLLMResolution{ + resolved := taskLLMResolution{ Name: name, Format: cfg.Provider(), Model: cfg.Model, Source: "environment", Available: true, - }, nil + } + if appliedID != 0 { + resolved.ProfileID = &appliedID + resolved.Source = "global_profile" + } + return resolved, nil } return taskLLMResolution{Source: "global", Reason: "No available LLM profile"}, nil } diff --git a/web/src/app/(main)/dashboard/page.tsx b/web/src/app/(main)/dashboard/page.tsx index a9cd1c9..700a16f 100644 --- a/web/src/app/(main)/dashboard/page.tsx +++ b/web/src/app/(main)/dashboard/page.tsx @@ -223,15 +223,13 @@ export default function DashboardPage() { let alive = true; const load = async () => { try { - const [traf, counts, agentList, mcpList, skillList, toolList, profileList, usage] = await Promise.all([ + const [traf, counts, agentList, mcpList, skillList, toolList] = await Promise.all([ api.traffic(0, 50), api.assetCounts(), api.agents(), api.mcpServers(), api.skills(), api.tools(), - api.llmProfiles(), - api.usageStats(365), ]); if (!alive) return; setTraffic(traf.exchanges ?? []); @@ -240,8 +238,6 @@ export default function DashboardPage() { setMcpServers(mcpList); setSkills(skillList); setTools(toolList); - setLLMProfiles(profileList); - setUsageStats(usage); } catch { /* transient errors */ } @@ -254,6 +250,30 @@ export default function DashboardPage() { }; }, []); + React.useEffect(() => { + let alive = true; + const load = () => { + void api + .usageStats(365) + .then((usage) => { + if (alive) setUsageStats(usage); + }) + .catch(() => {}); + void api + .llmProfiles() + .then((profiles) => { + if (alive) setLLMProfiles(profiles); + }) + .catch(() => {}); + }; + void load(); + const t = setInterval(load, 15000); + return () => { + alive = false; + clearInterval(t); + }; + }, []); + // ── derived ─────────────────────────────────────────────────────────────── const tasksByStatus = React.useMemo(() => { @@ -329,7 +349,7 @@ export default function DashboardPage() { const defaultProfileId = activeProfile ? Number(activeProfile.id) : null; // 数据源开关:旧版 = activity(task.tokens + 会话),新版 = llm_usage 计量账本。 - const [tokenVersion, setTokenVersion] = React.useState<"old" | "new">("old"); + const [tokenVersion, setTokenVersion] = React.useState<"old" | "new">("new"); // selected profile tab: "all" = 全部; number = specific profile id const [tokenTab, setTokenTab] = React.useState("all"); // day range for the daily bar chart diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index e1399e8..5513fb7 100644 --- a/web/src/app/(main)/function/findings/page.tsx +++ b/web/src/app/(main)/function/findings/page.tsx @@ -921,7 +921,7 @@ export default function FindingsPage() { Unlinked / Task deleted {(stats.tasks ?? []).map((t) => { const id = String(t.id); - const label = t.name || t.description || `Task #${id} (deleted)`; + const label = t.name || t.description || `Task #${id}`; return ( @@ -1053,7 +1053,8 @@ export default function FindingsPage() { : `Task #${group.task_id}`} - {group.task_description || "Source Task unavailable"} + {group.task_description || + (group.task_id === null ? "Source Task unavailable" : "No description")} diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx index f942b8d..873c920 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx @@ -11,6 +11,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { api } from "@/lib/api"; +import { type GraphTheme, observeGraphTheme, readGraphTheme } from "@/lib/graph-theme"; import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -227,16 +228,15 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] { const nd = (d: unknown) => d as G6NodeDatum; // 已测=实色高亮;范围内未测=灰;范围外/折叠=更淡的灰 + 虚线描边。 -function nodeFill(d: G6NodeDatum): string { - if (d.fold) return "#f1f5f9"; - if (!d.inScope) return "#e2e8f0"; - if (d.tested) return kindMeta[d.kind].hex; - return "#94a3b8"; +function nodeFill(d: G6NodeDatum, colors: GraphTheme): string { + if (!d.fold && !d.inScope) return colors.background; + if (!d.fold && d.tested) return kindMeta[d.kind].hex; + return colors.muted; } -function nodeStroke(d: G6NodeDatum): string { - if (d.fold || !d.inScope) return "#94a3b8"; - if (d.tested) return "#0f766e"; - return "#64748b"; +function nodeStroke(d: G6NodeDatum, colors: GraphTheme): string { + if (d.fold || !d.inScope) return colors.mutedForeground; + if (d.tested) return kindMeta[d.kind].hex; + return colors.ring; } // --------------------------------------------------------------------------- @@ -257,7 +257,7 @@ function RefList({ title, items }: { title: string; items: CoverageAssetRef[] }) return (

- {title}({items.length}) + {title} ({items.length})

{items.map((r) => ( @@ -528,36 +528,49 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera React.useEffect(() => { let destroyed = false; let graph: G6Graph | null = null; + let stopObserving: (() => void) | undefined; void (async () => { const { Graph } = await import("@antv/g6"); if (destroyed || !containerRef.current) return; + let colors = readGraphTheme(); + const mutedIcons = () => + Object.fromEntries( + Object.entries({ ...KIND_ICON, fold: FOLD_ICON }).map(([kind, icon]) => [ + kind, + icon.replaceAll("%23fff", encodeURIComponent(colors.foreground)), + ]), + ); + let icons = mutedIcons(); graph = new Graph({ container: containerRef.current, autoResize: true, autoFit: "view", - background: "#f0f2f7", + background: "transparent", node: { style: { size: (d: unknown) => nd(d).size, - fill: (d: unknown) => nodeFill(nd(d)), - stroke: (d: unknown) => nodeStroke(nd(d)), + fill: (d: unknown) => nodeFill(nd(d), colors), + stroke: (d: unknown) => nodeStroke(nd(d), colors), lineWidth: (d: unknown) => (nd(d).fold || !nd(d).inScope ? 1 : 1.5), lineDash: (d: unknown) => (nd(d).fold || !nd(d).inScope ? [3, 3] : [0]), - iconSrc: (d: unknown) => (nd(d).fold ? FOLD_ICON : KIND_ICON[nd(d).kind]), + iconSrc: (d: unknown) => { + const node = nd(d); + return node.fold ? icons.fold : node.tested ? KIND_ICON[node.kind] : icons[node.kind]; + }, iconWidth: (d: unknown) => Math.max(12, nd(d).size * 0.55), iconHeight: (d: unknown) => Math.max(12, nd(d).size * 0.55), labelText: (d: unknown) => nd(d).lbl, labelFontSize: 10, labelPlacement: "bottom", - labelFill: "#475569", + labelFill: () => colors.foreground, labelBackground: true, - labelBackgroundFill: "rgba(255,255,255,0.75)", + labelBackgroundFill: () => colors.card, labelBackgroundRadius: 3, labelPadding: [1, 3], }, }, edge: { - style: { stroke: "#cbd5e1", lineWidth: 1, endArrow: false }, + style: { stroke: () => colors.mutedForeground, strokeOpacity: 0.4, lineWidth: 1, endArrow: false }, }, layout: { type: "d3-force", @@ -588,9 +601,22 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera }); graphRef.current = graph; applyData(); + stopObserving = observeGraphTheme(() => { + if (!graph || graph.destroyed) return; + colors = readGraphTheme(); + icons = mutedIcons(); + // Refresh styles without restarting the force layout or resetting zoom. + const { node, edge } = graph.getOptions(); + if (node) graph.setNode(node); + if (edge) graph.setEdge(edge); + void graph.draw().catch((err) => { + if (!graph?.destroyed) console.error("[coverage-graph] theme:", err); + }); + }); })(); return () => { destroyed = true; + stopObserving?.(); // 先停布局再销毁:尽量缩短"布局在飞、context 被清空"的竞态窗口。 try { graph?.stopLayout(); @@ -630,7 +656,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera return (
-
+
{/* 图例 + 统计 + 刷新(叠加层) */}
@@ -676,12 +702,13 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera Tested (highlighted) - Untested + Untested )} - Out of scope + Out of + scope

diff --git a/web/src/app/(main)/function/tasks/detail/page.tsx b/web/src/app/(main)/function/tasks/detail/page.tsx index 0a00f6a..1d83036 100644 --- a/web/src/app/(main)/function/tasks/detail/page.tsx +++ b/web/src/app/(main)/function/tasks/detail/page.tsx @@ -361,8 +361,11 @@ function TaskDetailInner() { -

- {task.name || task.description} +

+ {task.name || task.description || `Task #${task.id}`}

{task.id} diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 49ed553..cfd1a00 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -1377,9 +1377,13 @@ function TaskNameEditor({ task, onRename }: { task: Task; onRename: (task: Task, - {task.name?.trim() ? task.name : Unnamed} + {task.name?.trim() ? ( + task.name + ) : ( + {task.description ? "Unnamed" : `Task #${task.id}`} + )}