From 2739a5ed1f977988ceee94df6ce220558e67d356 Mon Sep 17 00:00:00 2001 From: Jerry Xiao Date: Tue, 3 Nov 2026 01:53:30 -0800 Subject: [PATCH 01/26] tasks: thinking effort selection, rename tool, changelog createTask accepts thinking_effort=auto, binding the new task to the active model's provider-default variant (no thinking fields sent) so easy work doesn't pay for max reasoning; unset/max leaves the task on the max-thinking rotation pool. Bridge exposes the same field plus rename_task, and the create_task description now separates display name from long context. --- CHANGELOG.md | 13 ++++ bridge/index.ts | 38 +++++++++-- server/server.go | 19 ++++++ server/task_effort.go | 67 +++++++++++++++++++ server/task_effort_test.go | 43 ++++++++++++ server/task_metadata_test.go | 67 +++++++++++++++++++ web/src/app/(main)/dashboard/page.tsx | 2 +- .../tasks/detail/_tabs/coverage-graph-tab.tsx | 63 ++++++++++++----- web/src/lib/graph-theme.ts | 37 ++++++++++ 9 files changed, 323 insertions(+), 26 deletions(-) create mode 100644 server/task_effort.go create mode 100644 server/task_effort_test.go create mode 100644 web/src/lib/graph-theme.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e611bd..0bbaa5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,17 @@ # Changelog +## [Unreleased] - 2026-11-03 + +- Task thinking effort is selectable: `thinking_effort: "auto"` on task creation + binds the task to the active model's provider-default variant, while hard tasks + stay on the max-thinking rotation pool. Exposed to the iMessage front desk as a + `create_task` field, so the spawner picks effort by task difficulty. +- 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/bridge/index.ts b/bridge/index.ts index 8222ff1..38068bd 100644 --- a/bridge/index.ts +++ b/bridge/index.ts @@ -181,14 +181,15 @@ 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. Set name to the short display title; description is separate long-form context, never the task name. Include a clear goal. Set thinking_effort to auto for easy jobs, leave it unset 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: "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 (defaults to Untitled Task)." }, + llm_profile_id: { type: "number", description: "Optional explicit LLM profile id; usually omit so the pool rotates codex/deepseek/kimi" }, + thinking_effort: { type: "string", enum: ["max", "auto"], description: "Reasoning effort for this task: omit or 'max' for hard targets (the pool runs max thinking), 'auto' for easy/quick jobs so the provider picks a cheaper default effort." }, }, required: ["goal"], }, @@ -201,6 +202,7 @@ const TOOLS: Record = { goal: str(a.goal), description: str(a.description) || "Untitled Task", llm_profile_id: Number(a.llm_profile_id) > 0 ? Number(a.llm_profile_id) : undefined, + thinking_effort: a.thinking_effort === "auto" ? "auto" : undefined, seed_first_intent: true, }), signal: AbortSignal.timeout(15_000), @@ -210,6 +212,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 +294,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/server/server.go b/server/server.go index 5a97888..0c4b175 100644 --- a/server/server.go +++ b/server/server.go @@ -1455,6 +1455,11 @@ type createTaskReq struct { 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 轮)。 CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。 + // ThinkingEffort 选择任务的思考强度: + // ""/"max" = 不绑定,走轮询池(池内均为 max/xhigh); + // "auto" = 绑定到当前模型的 auto 变体(不发思考字段,由端点自行决定强度), + // 供低难度任务省掉最大档推理的开销。 + ThinkingEffort string `json:"thinking_effort,omitempty"` // InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。 InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"` } @@ -1471,6 +1476,20 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) { if len(req.LLMProfileIDs) == 0 && req.LLMProfileID != nil { req.LLMProfileIDs = []int64{*req.LLMProfileID} } + switch strings.ToLower(strings.TrimSpace(req.ThinkingEffort)) { + case "", ThinkingEffortMax: + // 高难度路径:不绑定,由轮询池驱动(池内配置均为最高档思考)。 + case ThinkingEffortAuto: + // 低难度路径:绑定到当前模型的 auto 变体;显式指定的链优先于该选择。 + if len(req.LLMProfileIDs) == 0 { + if id, ok := s.autoEffortProfile(); ok { + req.LLMProfileIDs = []int64{id} + } + } + 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 diff --git a/server/task_effort.go b/server/task_effort.go new file mode 100644 index 0000000..a8f0b14 --- /dev/null +++ b/server/task_effort.go @@ -0,0 +1,67 @@ +package server + +import ( + "strings" + + "pta/db" +) + +// Thinking effort for a task, chosen at creation time by the caller or by the +// spawner (the Auto agent / iMessage front desk): +// +// "" / "max" → leave the task unbound: the rotation pool drives it, and every +// profile in that pool already runs at max/xhigh. +// "auto" → bind the task to the "(auto)" variant of the active model, which +// sends no thinking fields, so the provider picks its own effort. +// +// Easy work should not pay for maximum reasoning; hard work keeps it. +const ( + ThinkingEffortMax = "max" + ThinkingEffortAuto = "auto" +) + +// pickAutoProfile finds the auto variant of the active profile: same wire format, +// base URL and model, but no thinking fields of its own and excluded from the +// rotation chain (variants exist only as bind targets). It never returns the +// active profile itself, since that one carries max effort. +func pickAutoProfile(active *db.LLMProfile, profs []*db.LLMProfile) (int64, bool) { + if active == nil { + return 0, false + } + for _, p := range profs { + if p == nil || p.ID == active.ID { + continue + } + if p.Format != active.Format || p.BaseURL != active.BaseURL || p.Model != active.Model { + continue + } + // The variant must genuinely be "auto": any explicit effort would make it + // just another pinned profile, not a provider-default one. + if strings.TrimSpace(p.ReasoningEffort) != "" || strings.TrimSpace(p.ThinkingType) != "" { + continue + } + if !p.PoolExclude { + continue + } + return p.ID, true + } + return 0, false +} + +// autoEffortProfile resolves the bind target for thinking_effort=auto against the +// live profile set. ok=false means there is no auto variant for the active model, +// in which case the task simply stays on the pool. +func (s *Server) autoEffortProfile() (int64, bool) { + if s.m == nil || s.m.pg == nil { + return 0, false + } + active, err := s.m.pg.ActiveProfile() + if err != nil { + return 0, false + } + profs, err := s.m.pg.ListProfiles() + if err != nil { + return 0, false + } + return pickAutoProfile(active, profs) +} diff --git a/server/task_effort_test.go b/server/task_effort_test.go new file mode 100644 index 0000000..55c7213 --- /dev/null +++ b/server/task_effort_test.go @@ -0,0 +1,43 @@ +package server + +import ( + "testing" + + "pta/db" +) + +// TestPickAutoProfile covers the effort routing used when a task is created with +// thinking_effort=auto: it must land on the provider-default variant of the ACTIVE +// model only — same format, base URL and model, no thinking fields, excluded from +// the rotation chain. Anything pinned to an effort, on another model, or in the +// pool is not an auto variant. +func TestPickAutoProfile(t *testing.T) { + active := &db.LLMProfile{ + ID: 17, Format: "openai-responses", BaseURL: "https://chatgpt.com/backend-api/codex", + Model: "gpt-6-sol", ReasoningEffort: "max", + } + auto := &db.LLMProfile{ + ID: 20, Format: active.Format, BaseURL: active.BaseURL, Model: active.Model, PoolExclude: true, + } + profs := []*db.LLMProfile{ + {ID: 16, Format: "openai", BaseURL: "https://api.deepseekv4pro.com/v1", Model: "deepseek-flash", ReasoningEffort: "xhigh"}, + active, + auto, + {ID: 21, Format: active.Format, BaseURL: active.BaseURL, Model: active.Model, PoolExclude: true, ReasoningEffort: "high"}, + {ID: 22, Format: active.Format, BaseURL: active.BaseURL, Model: "other-model", PoolExclude: true}, + {ID: 23, Format: active.Format, BaseURL: active.BaseURL, Model: active.Model, PoolExclude: false}, + } + + if id, ok := pickAutoProfile(active, profs); !ok || id != 20 { + t.Fatalf("pickAutoProfile = (%d, %v), want (20, true)", id, ok) + } + + // Same profiles without the auto variant: no bind target, task stays on the pool. + if _, ok := pickAutoProfile(active, []*db.LLMProfile{active, profs[0], profs[3], profs[4]}); ok { + t.Fatal("expected no auto variant when every candidate has an effort, another model, or pool membership") + } + + if _, ok := pickAutoProfile(nil, profs); ok { + t.Fatal("nil active profile must not bind anything") + } +} 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/web/src/app/(main)/dashboard/page.tsx b/web/src/app/(main)/dashboard/page.tsx index a9cd1c9..c9cf4e0 100644 --- a/web/src/app/(main)/dashboard/page.tsx +++ b/web/src/app/(main)/dashboard/page.tsx @@ -329,7 +329,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/tasks/detail/_tabs/coverage-graph-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx index f942b8d..82a4031 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 { observeGraphTheme, readGraphTheme } from "@/lib/graph-theme"; import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -227,16 +228,16 @@ 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"; +function nodeFill(d: G6NodeDatum, colors: ReturnType): string { + if (d.fold) return colors.muted; + if (!d.inScope) return colors.background; if (d.tested) return kindMeta[d.kind].hex; - return "#94a3b8"; + 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: ReturnType): string { + if (d.fold || !d.inScope) return colors.mutedForeground; + if (d.tested) return kindMeta[d.kind].hex; + return colors.ring; } // --------------------------------------------------------------------------- @@ -528,36 +529,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 +602,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 +657,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera return (
-
+
{/* 图例 + 统计 + 刷新(叠加层) */}
@@ -676,12 +703,12 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera Tested (highlighted) - Untested + Untested )} - Out of scope + Out of scope

diff --git a/web/src/lib/graph-theme.ts b/web/src/lib/graph-theme.ts new file mode 100644 index 0000000..b3b16f0 --- /dev/null +++ b/web/src/lib/graph-theme.ts @@ -0,0 +1,37 @@ +// G6's color parser does not understand the app's OKLCH CSS tokens. +// Let the browser convert them to sRGB before passing colors to the canvas. +export function readGraphTheme() { + const style = getComputedStyle(document.documentElement); + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) throw new Error("A 2D canvas context is required for the asset graph"); + + const color = (token: string) => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = style.getPropertyValue(token).trim(); + context.fillRect(0, 0, 1, 1); + const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data; + return `rgba(${r}, ${g}, ${b}, ${a / 255})`; + }; + + return { + background: color("--background"), + foreground: color("--foreground"), + card: color("--card"), + muted: color("--muted"), + mutedForeground: color("--muted-foreground"), + border: color("--border"), + ring: color("--ring"), + }; +} + +export function observeGraphTheme(onChange: () => void) { + // The preference provider applies explicit and system themes to this element. + const observer = new MutationObserver(onChange); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme-preset"], + }); + return () => observer.disconnect(); +} From 78c3592849f8a5f52153a3fd5a751b6ff4b27c23 Mon Sep 17 00:00:00 2001 From: Jerry Xiao Date: Tue, 3 Nov 2026 01:55:16 -0800 Subject: [PATCH 02/26] web: English asset graph, theme-aware graph colors, Token Usage defaults to New The graph legend/labels were Chinese and the canvas stayed light inside dark mode. Graph colors now derive from the app's CSS theme tokens and redraw on theme change, preserving layout and zoom. The LLM Token Usage panel opens on New, with Legacy still selectable. --- .../tasks/detail/_tabs/coverage-graph-tab.tsx | 11 ++- web/src/components/exploration-graph.tsx | Bin 27645 -> 27408 bytes web/src/lib/graph-theme.test.mjs | 79 ++++++++++++++++++ web/src/lib/graph-theme.ts | 26 +++++- 4 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 web/src/lib/graph-theme.test.mjs 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 82a4031..3b468af 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,7 +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 { observeGraphTheme, readGraphTheme } from "@/lib/graph-theme"; +import { type GraphTheme, observeGraphTheme, readGraphTheme } from "@/lib/graph-theme"; import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -228,13 +228,12 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] { const nd = (d: unknown) => d as G6NodeDatum; // 已测=实色高亮;范围内未测=灰;范围外/折叠=更淡的灰 + 虚线描边。 -function nodeFill(d: G6NodeDatum, colors: ReturnType): string { - if (d.fold) return colors.muted; - if (!d.inScope) return colors.background; - if (d.tested) return kindMeta[d.kind].hex; +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, colors: ReturnType): string { +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; diff --git a/web/src/components/exploration-graph.tsx b/web/src/components/exploration-graph.tsx index 5526eb703d81d01bde0f6c5ebe39f8a5dcf93c21..51686abdf736ddeaa23e1b5f32dde0c7ea270b24 100644 GIT binary patch delta 951 zcmZ8fPfSxu9L|H%TJR4dQk2a)JkikD2ZCUXtAJ^3AtCHqF4Z8DBX7i9K^s@X}FarfN@AeFpvtC9dsj&-nlZHc#+*Qnlp*pgz`* z8_`M5C6d^mgKWdChAD}K)aZA(7H$n?ICfzv{7-0`gb@*9pX#%Ci<_rg$q3-$~#s83mfhv5{3yc2Tl0dy09%@b9Qccmk*D^ zZGulb6H} z$Olx=-ma^x$LD2HATw2NGt^#8AfYQ*oVk_R#UU->w`VGdRnHEM8!$4_0z-qd!M!&4 z*N?NR<--}Hseb_ZMM2q|br4pw?J9EQSfr^|<%^ob9nr~+hOjo$s(u^pH@0f&Jt8C} z{;}TT0CXnzht5_!0`EqK!&-b<_Eq1Bwz^F@LAhpCfxZ(l7(Trn7LvtqR#1nsucr0X z4(LoReb@H->0LqidMX}6-E>2nZ^*|Xm-`9sCHH`r+X+vSEupitMBwJheGPBS!K{C#J5(%l!{|(N*Vb|~ZWAAMUoDYRo`^Bq?{NupWN|SL|BuDg zi_vWnc%SVWz_v{#CF%H^hw&6D^Fva!U$ z2xpQpczR~NImtarY!m5S*05s2g~Dhjg5I+ zm~#X#GaK4xf8D`#+;MXri*C!RZDbt&v)a_F*=ggKLe*=P=z>-e`NGL3P%W}l*2`vH bn#ZljpeF-E5X1=FMF{w`FSEHW+We-xL delta 1236 zcmb_bT}%^M6wZK7X<4&~{OE3WcPJXg^k=%1QqAfX3#h>eF}xYAtsUBi((1Ihu7pHc zH_)r#u)QG@F>ssuzY~UDtivd7As3?|kPw z_uMn^i2Ca>H948vfTz>q`HO0eiea1_?>WIaZ7saNZ9AjJXCr7NJrC)n;Lje;>kJS$ zlZc`2#1eLl_n+VvvFyz=c&J}^m)k%o@%Y_LGKs>M;E&Z|c(S^f&fcCCyY7->lom%O z@OU>2(bs7l84#}|pjL5%mMXz(#TcDQ^rDFkaHO=+gSQ_8@QfYPYd0JkWGn z<#Q#=GG(%k#4cObkSQiYS<+tQ$)K5Kg=TG-DwBa~nHA(t23KeXLVKlqlcSSqRfOi{ z>`sr*r49JnU6S%Ya*QPBogtU16O%CmXSJzwbpgeR=G|{?_?+ynu_Anu^;qqBfP9HI%g@Q z#i=BU_Ckr}M}-)k!KcrtNu&f`?4X5{mba8DlNv(5_K1lX!JA6qMNI)*+T~VA9%QTn znyly6eC%scWdmWC!`9l8;)PU?G@g|KETlp(wPQUr)vN-`uJ2&1v3Su99-NsUoQApD zO?ju>TI+swMJF7$egkjo^M%5?9I8Ni++q^46N#lZsC{1vmik|)&C8MDv)OPL*&tG~ zqm65GaQt3pK%|u)ao>dSv`Ix3q1jvLN+0T( zQ8Q%GrC%thDHYZnNaqyd;VwKlj{7f*)0daCo;BB~zCBpp!nS~vBh9CGX_&7V=x;TJ;Uk@X2E8kQhoq+=o%LQ diff --git a/web/src/lib/graph-theme.test.mjs b/web/src/lib/graph-theme.test.mjs new file mode 100644 index 0000000..0b3d6fe --- /dev/null +++ b/web/src/lib/graph-theme.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { observeGraphTheme, readGraphTheme } from "./graph-theme.ts"; + +test("graph colors follow root theme changes and release their observer", (t) => { + let dark = false; + let notify; + let disconnected = false; + const root = { classList: { contains: () => dark }, dataset: { themePreset: "default" } }; + const context = { + fillStyle: "", + clearRect() {}, + fillRect() {}, + getImageData() { + return { data: this.fillStyle === "dark" ? [20, 20, 20, 255] : [250, 250, 250, 255] }; + }, + }; + const globals = { + document: { + documentElement: root, + createElement(tag) { + assert.equal(tag, "canvas"); + return { getContext: () => context }; + }, + }, + getComputedStyle(element) { + assert.equal(element, root); + return { + getPropertyValue(token) { + const surface = ["--background", "--card", "--muted"].includes(token); + return surface === dark ? "dark" : "light"; + }, + }; + }, + MutationObserver: class { + constructor(callback) { + notify = callback; + } + observe(element, options) { + assert.equal(element, root); + assert.deepEqual(options.attributeFilter, ["class", "data-theme-preset"]); + } + disconnect() { + disconnected = true; + } + }, + }; + for (const [name, value] of Object.entries(globals)) { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, name); + Object.defineProperty(globalThis, name, { value, configurable: true }); + t.after(() => { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else delete globalThis[name]; + }); + } + + const light = readGraphTheme(); + assert.equal(light.background, "rgba(250, 250, 250, 1)"); + assert.equal(light.foreground, "rgba(20, 20, 20, 1)"); + const updates = []; + const stop = observeGraphTheme(() => updates.push(readGraphTheme())); + + notify(); // Unrelated class mutations, such as disable-transitions, do not redraw. + assert.equal(updates.length, 0); + dark = true; + notify(); + assert.equal(updates[0].background, light.foreground); + assert.equal(updates[0].foreground, light.background); + assert.equal(updates[0].muted, light.foreground); + root.dataset.themePreset = "soft-pop"; + notify(); + assert.equal(updates.length, 2); + dark = false; + notify(); + assert.deepEqual(updates[2], light); + stop(); + assert.equal(disconnected, true); +}); diff --git a/web/src/lib/graph-theme.ts b/web/src/lib/graph-theme.ts index b3b16f0..665585f 100644 --- a/web/src/lib/graph-theme.ts +++ b/web/src/lib/graph-theme.ts @@ -1,6 +1,15 @@ +export interface GraphTheme { + background: string; + foreground: string; + card: string; + muted: string; + mutedForeground: string; + ring: string; +} + // G6's color parser does not understand the app's OKLCH CSS tokens. // Let the browser convert them to sRGB before passing colors to the canvas. -export function readGraphTheme() { +export function readGraphTheme(): GraphTheme { const style = getComputedStyle(document.documentElement); const canvas = document.createElement("canvas"); canvas.width = canvas.height = 1; @@ -21,15 +30,24 @@ export function readGraphTheme() { card: color("--card"), muted: color("--muted"), mutedForeground: color("--muted-foreground"), - border: color("--border"), ring: color("--ring"), }; } export function observeGraphTheme(onChange: () => void) { // The preference provider applies explicit and system themes to this element. - const observer = new MutationObserver(onChange); - observer.observe(document.documentElement, { + const root = document.documentElement; + let dark = root.classList.contains("dark"); + let preset = root.dataset.themePreset; + const observer = new MutationObserver(() => { + const nextDark = root.classList.contains("dark"); + const nextPreset = root.dataset.themePreset; + if (nextDark === dark && nextPreset === preset) return; + dark = nextDark; + preset = nextPreset; + onChange(); + }); + observer.observe(root, { attributes: true, attributeFilter: ["class", "data-theme-preset"], }); From d8c5513face3bb5ca7670afa652dad3689fa0f32 Mon Sep 17 00:00:00 2001 From: Jerry Xiao Date: Tue, 3 Nov 2026 02:07:42 -0800 Subject: [PATCH 03/26] no-mistakes(review): Remove redundant authorization header spreads --- CHANGELOG.md | 8 +- bridge/index.ts | 14 +- db/schema.sql | 1 + db/task_archives_restore.go | 3 +- db/task_archives_test.go | 4 +- db/tasks.go | 13 +- server/dto.go | 2 + server/llmpool.go | 20 +- server/manager.go | 4 +- server/orchestration.go | 16 +- server/server.go | 99 ++---- server/side_questions.go | 22 +- server/side_questions_test.go | 24 +- server/task_effort.go | 321 +++++++++++++++--- server/task_effort_test.go | 167 +++++++-- server/task_llm.go | 30 +- server/task_resolution.go | 10 +- .../tasks/detail/_tabs/coverage-graph-tab.tsx | 2 +- web/src/app/(main)/function/tasks/page.tsx | 20 ++ web/src/lib/api.ts | 8 +- web/src/lib/types.ts | 1 + 21 files changed, 590 insertions(+), 199 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bbaa5d..ace968c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Changelog ## [Unreleased] - 2026-11-03 -- Task thinking effort is selectable: `thinking_effort: "auto"` on task creation - binds the task to the active model's provider-default variant, while hard tasks - stay on the max-thinking rotation pool. Exposed to the iMessage front desk as a - `create_task` field, so the spawner picks effort by task difficulty. +- Task thinking effort is selectable in task creation, the iMessage `create_task` + tool, and `spawn_task`. `auto` uses each model's provider default; `max` sends + maximum effort across task agents and pool fallback. 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. diff --git a/bridge/index.ts b/bridge/index.ts index 38068bd..815545d 100644 --- a/bridge/index.ts +++ b/bridge/index.ts @@ -181,28 +181,30 @@ const TOOLS: Record = { }, }, create_task: { - description: "Create a new jcyber task targeting a domain/host. Each target gets its own task. Set name to the short display title; description is separate long-form context, never the task name. Include a clear goal. Set thinking_effort to auto for easy jobs, leave it unset for hard ones. Returns the new task id.", + description: "Create a new jcyber task targeting a domain/host. Each target gets its own task. Set name to the short display title; description is separate long-form context, never the task name. Include a clear goal. Set thinking_effort to auto for easy jobs or max for hard ones. Returns the new task id.", parameters: { type: "object", properties: { name: { type: "string", description: "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: "Optional long-form task context/instructions, separate from the display name (defaults to Untitled Task)." }, + 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; usually omit so the pool rotates codex/deepseek/kimi" }, thinking_effort: { type: "string", enum: ["max", "auto"], description: "Reasoning effort for this task: omit or 'max' for hard targets (the pool runs max thinking), 'auto' for easy/quick jobs so the provider picks a cheaper default effort." }, }, - required: ["goal"], + required: ["name", "goal"], }, run: async (a) => { + const name = str(a.name).trim(); + if (!name) throw new Error("task name is required"); 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 === "auto" ? "auto" : undefined, + thinking_effort: a.thinking_effort, seed_first_intent: true, }), signal: AbortSignal.timeout(15_000), 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..fea72fe 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),让不关心该开关的创建 @@ -219,6 +221,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 +229,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 +347,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/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/llmpool.go b/server/llmpool.go index f4eb70c..b329e8f 100644 --- a/server/llmpool.go +++ b/server/llmpool.go @@ -59,8 +59,8 @@ 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, rank int, effort string) *llmpool.Member { + prov, cfg, ok := s.providerForProfileEffort(p.ID, effort) if !ok { return nil } @@ -92,7 +92,7 @@ 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 } @@ -134,7 +134,7 @@ func (s *Server) poolChain(headID int64, headProv llm.Provider, headCfg agent.Co if p.IsDefault { rank = llmpool.RankActive - 1 } - if m := s.poolMember(p, rank); m != nil { + if m := s.poolMember(p, rank, effort); m != nil { members = append(members, m) } } @@ -147,7 +147,11 @@ func (s *Server) poolChain(headID int64, headProv llm.Provider, headCfg agent.Co // 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 +168,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.poolForBindingEffort(id, prov, cfg, "") +} + +func (s *Server) poolForBindingEffort(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 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/orchestration.go b/server/orchestration.go index a9f5eac..dd8b6fa 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. @@ -247,6 +247,7 @@ func (s *Server) toolSpawnTask() actool.CoreTool { "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 for hard work; auto lets the provider choose its default effort. 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)."}, @@ -258,6 +259,7 @@ func (s *Server) toolSpawnTask() actool.CoreTool { 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"` @@ -272,6 +274,13 @@ func (s *Server) toolSpawnTask() actool.CoreTool { 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 @@ -308,6 +317,7 @@ func (s *Server) toolSpawnTask() actool.CoreTool { t, err := s.m.CreateTaskWithOptions(a.Description, a.Goal, db.TaskCreateOptions{ SourceTaskIDs: sourceIDs, LLMProfileIDs: llmIDs, + ThinkingEffort: a.ThinkingEffort, TimeoutSeconds: a.TimeoutSeconds, PlanHeartbeatSeconds: a.PlanHeartbeatSeconds, }) @@ -514,7 +524,7 @@ func (s *Server) seedOrchestrationTools() { // 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" + const flag = "tool_schema_refresh_v8_spawn_effort" if v, _, _ := s.m.pg.GetSetting(flag); v == "true" { return } diff --git a/server/server.go b/server/server.go index 0c4b175..5bbb22e 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 @@ -93,14 +92,12 @@ 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 + provCacheGen uint64 // llmHealth is the process-wide circuit-breaker state for LLM failover (轮询). // It deliberately lives OUTSIDE the provider caches: rebuilding the chain @@ -149,7 +146,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 一次; @@ -435,17 +434,12 @@ func (s *Server) applyLLM(cfg agent.Config) error { } } if prov == nil { - var err error - prov, err = cfg.NewProvider() - if err != nil { - return err + s.resetEnvironmentEffortProviders() + var ok bool + prov, _, ok = s.environmentProviderForEffort(cfg, "") + if !ok { + 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) } s.cfgMu.Lock() s.llmDirect = prov @@ -560,40 +554,7 @@ 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 @@ -637,6 +598,9 @@ func (s *Server) invalidateProfileAgents() { s.provCacheMu.Lock() s.provCacheGen++ s.provByProfile = map[int64]*provEntry{} + s.provByEffort = map[effortProfileKey]*provEntry{} + s.provShared = map[int64]*profileEffortRouter{} + s.globalByEffort = map[string]*provEntry{} s.provCacheMu.Unlock() s.invalidateTaskAgents() } @@ -1455,11 +1419,7 @@ type createTaskReq struct { 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 轮)。 CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。 - // ThinkingEffort 选择任务的思考强度: - // ""/"max" = 不绑定,走轮询池(池内均为 max/xhigh); - // "auto" = 绑定到当前模型的 auto 变体(不发思考字段,由端点自行决定强度), - // 供低难度任务省掉最大档推理的开销。 - ThinkingEffort string `json:"thinking_effort,omitempty"` + ThinkingEffort string `json:"thinking_effort,omitempty"` // InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。 InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"` } @@ -1476,16 +1436,10 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) { if len(req.LLMProfileIDs) == 0 && req.LLMProfileID != nil { req.LLMProfileIDs = []int64{*req.LLMProfileID} } - switch strings.ToLower(strings.TrimSpace(req.ThinkingEffort)) { - case "", ThinkingEffortMax: - // 高难度路径:不绑定,由轮询池驱动(池内配置均为最高档思考)。 - case ThinkingEffortAuto: - // 低难度路径:绑定到当前模型的 auto 变体;显式指定的链优先于该选择。 - if len(req.LLMProfileIDs) == 0 { - if id, ok := s.autoEffortProfile(); ok { - req.LLMProfileIDs = []int64{id} - } - } + switch req.ThinkingEffort { + case "": + req.ThinkingEffort = ThinkingEffortMax + case ThinkingEffortMax, ThinkingEffortAuto: default: writeErr(w, 400, `thinking_effort must be "auto" or "max"`) return @@ -1530,6 +1484,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, diff --git a/server/side_questions.go b/server/side_questions.go index fba4b60..00b8793 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, ThinkingEffortMax} { + 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 { @@ -190,16 +199,19 @@ func (s *Server) sideProvider(model sidequestion.Model) (llm.Provider, error) { 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 { + effort, matched := sideModelEffort(current, model) + if !exists || !matched { 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) + p, cfg, ok = s.providerForProfileEffort(model.ProfileID, effort) } else { 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.globalProviderForEffort(effort) + } } 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..7d4e741 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 { @@ -444,6 +444,26 @@ 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) + } + } +} + 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_effort.go b/server/task_effort.go index a8f0b14..9615bd8 100644 --- a/server/task_effort.go +++ b/server/task_effort.go @@ -1,67 +1,298 @@ package server import ( - "strings" + "context" + "errors" + "iter" + "log" + "sync" - "pta/db" + "normasdk/llm" + "pta/agent" + "pta/llmrec" ) -// Thinking effort for a task, chosen at creation time by the caller or by the -// spawner (the Auto agent / iMessage front desk): -// -// "" / "max" → leave the task unbound: the rotation pool drives it, and every -// profile in that pool already runs at max/xhigh. -// "auto" → bind the task to the "(auto)" variant of the active model, which -// sends no thinking fields, so the provider picks its own effort. -// -// Easy work should not pay for maximum reasoning; hard work keeps it. const ( ThinkingEffortMax = "max" ThinkingEffortAuto = "auto" ) -// pickAutoProfile finds the auto variant of the active profile: same wire format, -// base URL and model, but no thinking fields of its own and excluded from the -// rotation chain (variants exist only as bind targets). It never returns the -// active profile itself, since that one carries max effort. -func pickAutoProfile(active *db.LLMProfile, profs []*db.LLMProfile) (int64, bool) { - if active == nil { - return 0, false - } - for _, p := range profs { - if p == nil || p.ID == active.ID { - continue - } - if p.Format != active.Format || p.BaseURL != active.BaseURL || p.Model != active.Model { - continue +type effortProfileKey struct { + id int64 + effort string +} + +type effortContextKey struct{} + +type profileEffortRouter struct { + mu sync.RWMutex + providers map[string]llm.Provider + limited llm.Provider +} + +func (r *profileEffortRouter) provider(ctx context.Context) llm.Provider { + effort, _ := ctx.Value(effortContextKey{}).(string) + r.mu.RLock() + prov := r.providers[effort] + r.mu.RUnlock() + 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 + effort string +} + +func (v *profileEffortView) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + return v.shared.Stream(context.WithValue(ctx, effortContextKey{}, v.effort), req) +} + +func (v *profileEffortView) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { + return v.shared.Complete(context.WithValue(ctx, effortContextKey{}, v.effort), req) +} + +func resolvedEffortConfig(cfg agent.Config, effort string) agent.Config { + switch effort { + case ThinkingEffortAuto: + cfg.ThinkingType = "" + cfg.ReasoningEffort = "" + case ThinkingEffortMax: + if cfg.ThinkingType == "disabled" { + cfg.ThinkingType = "" } - // The variant must genuinely be "auto": any explicit effort would make it - // just another pinned profile, not a provider-default one. - if strings.TrimSpace(p.ReasoningEffort) != "" || strings.TrimSpace(p.ThinkingType) != "" { - continue + cfg.ReasoningEffort = ThinkingEffortMax + } + return cfg +} + +func (s *Server) providerForProfileEffort(id int64, effort string) (llm.Provider, agent.Config, bool) { + key := effortProfileKey{id: id, effort: effort} + s.provCacheMu.Lock() + var cached *provEntry + if effort == "" { + cached = s.provByProfile[id] + } else { + cached = s.provByEffort[key] + } + if cached != nil { + s.provCacheMu.Unlock() + return cached.prov, cached.cfg, true + } + generation := s.provCacheGen + s.provCacheMu.Unlock() + cfg, ok := s.loadProfileConfig(id) + if !ok { + return nil, agent.Config{}, false + } + cfg = resolvedEffortConfig(cfg, effort) + providerCfg := cfg + providerCfg.RatePerSecond = 0 + providerCfg.RatePerMinute = 0 + prov, err := providerCfg.NewProvider() + if err != nil { + log.Printf("[engine] build provider for LLM profile %d failed: %v", id, err) + return nil, agent.Config{}, false + } + 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.providerForProfileEffort(id, effort) + } + if effort == "" { + cached = s.provByProfile[id] + } else { + cached = s.provByEffort[key] + } + if cached != nil { + prov, cfg = cached.prov, cached.cfg + } else { + shared := s.provShared[id] + if shared == nil { + shared = &profileEffortRouter{providers: map[string]llm.Provider{}} + 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 } - if !p.PoolExclude { - continue + shared.mu.Lock() + shared.providers[effort] = prov + shared.mu.Unlock() + prov = &profileEffortView{shared: shared.limited, effort: effort} + entry := &provEntry{prov: prov, cfg: cfg} + 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 } - return p.ID, true + log.Printf("[engine] built provider for LLM profile %d (%s / %s / %s)", id, cfg.Provider(), cfg.Model, effort) } - return 0, false + s.provCacheMu.Unlock() + return prov, cfg, true } -// autoEffortProfile resolves the bind target for thinking_effort=auto against the -// live profile set. ok=false means there is no auto variant for the active model, -// in which case the task simply stays on the pool. -func (s *Server) autoEffortProfile() (int64, bool) { - if s.m == nil || s.m.pg == nil { - return 0, false +func (s *Server) environmentProviderForEffort(baseCfg agent.Config, effort string) (llm.Provider, agent.Config, bool) { + key := effortProfileKey{effort: effort} + s.provCacheMu.Lock() + var cached *provEntry + if effort == "" { + cached = s.provByProfile[0] + } else { + cached = s.provByEffort[key] } - active, err := s.m.pg.ActiveProfile() - if err != nil { - return 0, false + if cached != nil { + s.provCacheMu.Unlock() + return cached.prov, cached.cfg, true } - profs, err := s.m.pg.ListProfiles() + generation := s.provCacheGen + s.provCacheMu.Unlock() + cfg := resolvedEffortConfig(baseCfg, effort) + providerCfg := cfg + providerCfg.RatePerSecond = 0 + providerCfg.RatePerMinute = 0 + prov, err := providerCfg.NewProvider() if err != nil { - return 0, false + return nil, agent.Config{}, false + } + s.cfgMu.Lock() + name := s.llmProf + s.cfgMu.Unlock() + prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, name, cfg.ThinkingType, cfg.ReasoningEffort, s.m.LLMRecordEnabled) + prov = bindSideProvider(prov, cfg, 0, name) + s.provCacheMu.Lock() + if generation != s.provCacheGen { + s.provCacheMu.Unlock() + return s.environmentProviderForEffort(baseCfg, effort) + } + if effort == "" { + cached = s.provByProfile[0] + } else { + cached = s.provByEffort[key] + } + if cached != nil { + prov, cfg = cached.prov, cached.cfg + } else { + shared := s.provShared[0] + if shared == nil { + shared = &profileEffortRouter{providers: map[string]llm.Provider{}} + shared.limited = llm.RateLimited(shared, llm.RateLimit{PerSecond: cfg.RatePerSecond, PerMinute: cfg.RatePerMinute}) + if s.provShared == nil { + s.provShared = map[int64]*profileEffortRouter{} + } + s.provShared[0] = shared + } + shared.mu.Lock() + shared.providers[effort] = prov + shared.mu.Unlock() + prov = &profileEffortView{shared: shared.limited, effort: effort} + entry := &provEntry{prov: prov, cfg: cfg} + if effort == "" { + if s.provByProfile == nil { + s.provByProfile = map[int64]*provEntry{} + } + s.provByProfile[0] = entry + } else { + if s.provByEffort == nil { + s.provByEffort = map[effortProfileKey]*provEntry{} + } + s.provByEffort[key] = entry + } + } + s.provCacheMu.Unlock() + return prov, cfg, true +} + +func (s *Server) resetEnvironmentEffortProviders() { + s.provCacheMu.Lock() + s.provCacheGen++ + delete(s.provByProfile, 0) + delete(s.provShared, 0) + delete(s.provByEffort, effortProfileKey{effort: ThinkingEffortAuto}) + delete(s.provByEffort, effortProfileKey{effort: ThinkingEffortMax}) + s.globalByEffort = map[string]*provEntry{} + s.provCacheMu.Unlock() +} + +func (s *Server) globalProviderForEffort(effort string) (llm.Provider, agent.Config, bool) { + if effort == "" { + return s.globalProvider() + } + s.cfgMu.Lock() + on := s.llmOn + s.cfgMu.Unlock() + if !on { + return nil, agent.Config{}, false + } + s.provCacheMu.Lock() + if e := s.globalByEffort[effort]; e != nil { + s.provCacheMu.Unlock() + return e.prov, e.cfg, true + } + generation := s.provCacheGen + s.provCacheMu.Unlock() + var prov llm.Provider + var cfg agent.Config + if s.m != nil && s.m.pg != nil { + if p, err := s.m.pg.ActiveProfile(); err == nil && p != nil { + var ok bool + prov, cfg, ok = s.providerForProfileEffort(p.ID, effort) + if ok { + prov = s.poolForActiveEffort(p.ID, prov, cfg, effort) + } + } + } + if prov == nil { + s.cfgMu.Lock() + on, baseCfg := s.llmOn, s.llmCfg + s.cfgMu.Unlock() + if !on || s.m == nil { + return nil, agent.Config{}, false + } + var ok bool + prov, cfg, ok = s.environmentProviderForEffort(baseCfg, effort) + if !ok { + return nil, agent.Config{}, false + } + } + s.provCacheMu.Lock() + if generation != s.provCacheGen { + s.provCacheMu.Unlock() + return s.globalProviderForEffort(effort) + } + 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} } - return pickAutoProfile(active, profs) + s.provCacheMu.Unlock() + return prov, cfg, true } diff --git a/server/task_effort_test.go b/server/task_effort_test.go index 55c7213..9f0041b 100644 --- a/server/task_effort_test.go +++ b/server/task_effort_test.go @@ -1,43 +1,160 @@ package server import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" "testing" + "time" + "normasdk/llm" + "pta/agent" "pta/db" ) -// TestPickAutoProfile covers the effort routing used when a task is created with -// thinking_effort=auto: it must land on the provider-default variant of the ACTIVE -// model only — same format, base URL and model, no thinking fields, excluded from -// the rotation chain. Anything pinned to an effort, on another model, or in the -// pool is not an auto variant. -func TestPickAutoProfile(t *testing.T) { - active := &db.LLMProfile{ - ID: 17, Format: "openai-responses", BaseURL: "https://chatgpt.com/backend-api/codex", - Model: "gpt-6-sol", ReasoningEffort: "max", +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) } - auto := &db.LLMProfile{ - ID: 20, Format: active.Format, BaseURL: active.BaseURL, Model: active.Model, PoolExclude: true, + reasoning, present := body["reasoning"] + if effort == ThinkingEffortAuto { + if present { + t.Fatalf("auto request sent reasoning: %v", reasoning) + } + return } - profs := []*db.LLMProfile{ - {ID: 16, Format: "openai", BaseURL: "https://api.deepseekv4pro.com/v1", Model: "deepseek-flash", ReasoningEffort: "xhigh"}, - active, - auto, - {ID: 21, Format: active.Format, BaseURL: active.BaseURL, Model: active.Model, PoolExclude: true, ReasoningEffort: "high"}, - {ID: 22, Format: active.Format, BaseURL: active.BaseURL, Model: "other-model", PoolExclude: true}, - {ID: 23, Format: active.Format, BaseURL: active.BaseURL, Model: active.Model, PoolExclude: false}, + fields, ok := reasoning.(map[string]any) + if !ok || fields["effort"] != effort { + t.Fatalf("request reasoning = %v, want effort %s", reasoning, effort) } +} - if id, ok := pickAutoProfile(active, profs); !ok || id != 20 { - t.Fatalf("pickAutoProfile = (%d, %v), want (20, true)", id, ok) +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.ThinkingType != "enabled" || max.ReasoningEffort != "max" || max.Model != base.Model { + t.Fatalf("max config = %+v, want max effort on chosen model", max) + } + disabled := resolvedEffortConfig(agent.Config{ThinkingType: "disabled", ReasoningEffort: "high"}, ThinkingEffortMax) + if disabled.ThinkingType != "" || disabled.ReasoningEffort != "max" { + t.Fatalf("disabled profile max config = %+v, want max effort without disabled thinking", disabled) + } + if legacy := resolvedEffortConfig(base, ""); legacy != base { + t.Fatalf("legacy config = %+v, want unchanged %+v", legacy, base) + } +} - // Same profiles without the auto variant: no bind target, task stays on the pool. - if _, ok := pickAutoProfile(active, []*db.LLMProfile{active, profs[0], profs[3], profs[4]}); ok { - t.Fatal("expected no auto variant when every candidate has an effort, another model, or pool membership") +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: "wire-model", ThinkingType: "enabled", ReasoningEffort: "high"} + for _, effort := range []string{ThinkingEffortAuto, ThinkingEffortMax} { + cfg := resolvedEffortConfig(base, effort) + prov, err := cfg.NewProvider() + if err != nil { + t.Fatal(err) + } + assertEffortWire(t, effortWireRequest(t, prov, requests), "wire-model", effort) } +} - if _, ok := pickAutoProfile(nil, profs); ok { - t.Fatal("nil active profile must not bind anything") +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) } + assertEffortWire(t, effortWireRequest(t, selection.provider, requests), "chain-model", ThinkingEffortMax) } diff --git a/server/task_llm.go b/server/task_llm.go index 08edb21..6a51eee 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 @@ -492,15 +492,19 @@ 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) { id := s.effectiveProfileForAgent(agentKey, nil) if id == nil { return nil, agent.Config{}, false } - prov, cfg, ok := s.providerForProfile(*id) + prov, cfg, ok := s.providerForProfileEffort(*id, effort) if !ok { return nil, agent.Config{}, false } - return s.poolForBinding(*id, prov, cfg), cfg, true + return s.poolForBindingEffort(*id, prov, cfg, effort), cfg, true } // globalProvider returns the process-wide provider (persisted active profile or @@ -527,13 +531,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_resolution.go b/server/task_resolution.go index ede7339..7775615 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"} } @@ -33,7 +33,7 @@ func (s *Server) resolutionFromProfile(p *db.LLMProfile, source string) taskLLMR result.Reason = "LLM profile has no API key" return result } - if _, _, ok := s.providerForProfile(p.ID); !ok { + if _, _, ok := s.providerForProfileEffort(p.ID, effort); !ok { result.Reason = "Invalid or unsupported LLM profile format" return result } @@ -58,7 +58,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,14 +73,14 @@ 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 + return s.resolutionFromProfile(p, "task_chain", t.ThinkingEffort), 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") + resolved := s.resolutionFromProfile(p, "global_profile", t.ThinkingEffort) if resolved.Available { return resolved, nil } 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 3b468af..95cb4f2 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 @@ -257,7 +257,7 @@ function RefList({ title, items }: { title: string; items: CoverageAssetRef[] }) return (

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

{items.map((r) => ( diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 49ed553..e926e8c 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -3126,6 +3126,7 @@ function CreateTaskSheet({ const [companies, setCompanies] = React.useState([]); const [sourceTaskIDs, setSourceTaskIDs] = React.useState([]); const [companyIDs, setCompanyIDs] = React.useState([]); + const [thinkingEffort, setThinkingEffort] = React.useState<"auto" | "max">("max"); const [llmProfileIDs, setLLMProfileIDs] = React.useState([]); const [creating, setCreating] = React.useState(false); const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时 @@ -3192,6 +3193,7 @@ function CreateTaskSheet({ categoryId: categoryID, description: description.trim(), goal: goal.trim(), + thinkingEffort, llmProfileIds: llmProfileIDs.map(Number), sourceTaskIds: sourceTaskIDs, companyIds: companyIDs, @@ -3211,6 +3213,7 @@ function CreateTaskSheet({ setSelectedTemplateID(null); setSourceTaskIDs([]); setCompanyIDs([]); + setThinkingEffort("max"); setLLMProfileIDs([]); setTimeoutMin(""); setHeartbeatMin("10"); @@ -3371,6 +3374,23 @@ function CreateTaskSheet({ when no Allow rules are configured. + + Thinking Effort + + LLM Profile Chain Date: Tue, 3 Nov 2026 02:51:53 -0800 Subject: [PATCH 04/26] no-mistakes(review): Preserve configured effort and simplify task creation --- CHANGELOG.md | 6 +++--- bridge/index.ts | 6 +++--- server/orchestration.go | 2 +- server/task_effort.go | 8 +------- server/task_effort_test.go | 20 ++++++++++++-------- web/src/app/(main)/function/tasks/page.tsx | 4 ++-- web/src/lib/api.ts | 4 ++-- 7 files changed, 24 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ace968c..26425de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ ## [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` sends - maximum effort across task agents and pool fallback. Existing tasks keep their - profile settings. + 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. diff --git a/bridge/index.ts b/bridge/index.ts index 815545d..aad2737 100644 --- a/bridge/index.ts +++ b/bridge/index.ts @@ -181,15 +181,15 @@ const TOOLS: Record = { }, }, create_task: { - description: "Create a new jcyber task targeting a domain/host. Each target gets its own task. Set name to the short display title; description is separate long-form context, never the task name. Include a clear goal. Set thinking_effort to auto for easy jobs or max for hard ones. Returns the new task id.", + description: "Create a new jcyber task targeting a domain/host. Each target gets its own task. Set name to the short display title; description is separate long-form context, never the task name. Include a clear 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: "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: "Optional long-form task context/instructions, separate from the display name." }, - llm_profile_id: { type: "number", description: "Optional explicit LLM profile id; usually omit so the pool rotates codex/deepseek/kimi" }, - thinking_effort: { type: "string", enum: ["max", "auto"], description: "Reasoning effort for this task: omit or 'max' for hard targets (the pool runs max thinking), 'auto' for easy/quick jobs so the provider picks a cheaper default effort." }, + 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: ["name", "goal"], }, diff --git a/server/orchestration.go b/server/orchestration.go index dd8b6fa..50fea40 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -247,7 +247,7 @@ func (s *Server) toolSpawnTask() actool.CoreTool { "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 for hard work; auto lets the provider choose its default effort. Defaults to max."}, + "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)."}, diff --git a/server/task_effort.go b/server/task_effort.go index 9615bd8..d209dd7 100644 --- a/server/task_effort.go +++ b/server/task_effort.go @@ -68,15 +68,9 @@ func (v *profileEffortView) Complete(ctx context.Context, req llm.CompletionRequ } func resolvedEffortConfig(cfg agent.Config, effort string) agent.Config { - switch effort { - case ThinkingEffortAuto: + if effort == ThinkingEffortAuto { cfg.ThinkingType = "" cfg.ReasoningEffort = "" - case ThinkingEffortMax: - if cfg.ThinkingType == "disabled" { - cfg.ThinkingType = "" - } - cfg.ReasoningEffort = ThinkingEffortMax } return cfg } diff --git a/server/task_effort_test.go b/server/task_effort_test.go index 9f0041b..ac3bd84 100644 --- a/server/task_effort_test.go +++ b/server/task_effort_test.go @@ -70,12 +70,12 @@ func TestResolvedEffortConfig(t *testing.T) { t.Fatalf("auto config = %+v, want provider-default thinking on chosen model", auto) } max := resolvedEffortConfig(base, ThinkingEffortMax) - if max.ThinkingType != "enabled" || max.ReasoningEffort != "max" || max.Model != base.Model { - t.Fatalf("max config = %+v, want max effort on chosen model", max) + if max != base { + t.Fatalf("max config = %+v, want selected profile config %+v", max, base) } - disabled := resolvedEffortConfig(agent.Config{ThinkingType: "disabled", ReasoningEffort: "high"}, ThinkingEffortMax) - if disabled.ThinkingType != "" || disabled.ReasoningEffort != "max" { - t.Fatalf("disabled profile max config = %+v, want max effort without disabled thinking", disabled) + 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) @@ -85,14 +85,18 @@ func TestResolvedEffortConfig(t *testing.T) { 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: "wire-model", ThinkingType: "enabled", ReasoningEffort: "high"} + 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) } - assertEffortWire(t, effortWireRequest(t, prov, requests), "wire-model", effort) + wireEffort := effort + if effort == ThinkingEffortMax { + wireEffort = base.ReasoningEffort + } + assertEffortWire(t, effortWireRequest(t, prov, requests), base.Model, wireEffort) } } @@ -156,5 +160,5 @@ func TestTaskEffortRoutingAcrossBindings(t *testing.T) { if err != nil { t.Fatal(err) } - assertEffortWire(t, effortWireRequest(t, selection.provider, requests), "chain-model", ThinkingEffortMax) + assertEffortWire(t, effortWireRequest(t, selection.provider, requests), "chain-model", "high") } diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index e926e8c..09b2a51 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -3378,14 +3378,14 @@ function CreateTaskSheet({ Thinking Effort setName(e.target.value)} /> @@ -3291,11 +3291,11 @@ function CreateTaskSheet({
- +