Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2739a5e
tasks: thinking effort selection, rename tool, changelog
i098 Nov 3, 2026
78c3592
web: English asset graph, theme-aware graph colors, Token Usage defau…
i098 Nov 3, 2026
d8c5513
no-mistakes(review): Remove redundant authorization header spreads
i098 Nov 3, 2026
bb724f5
no-mistakes(review): Preserve configured effort and simplify task cre…
i098 Nov 3, 2026
8d0eb5f
no-mistakes(review): Reuse existing providers for max task effort
i098 Nov 3, 2026
dc44b0f
no-mistakes(review): Preserve bridge targets and applied auto configu…
i098 Nov 3, 2026
54d643f
no-mistakes(review): Fix task seeding, auto cache, and graph labels
i098 Nov 3, 2026
a73b4ba
no-mistakes(review): Keep task scope stable and simplify provider rou…
i098 Nov 3, 2026
ce60686
no-mistakes(review): Persist creation targets and correct auto pool a…
i098 Nov 3, 2026
847b7c0
no-mistakes(review): Preserve spawned targets and fix graph and usage…
i098 Nov 3, 2026
a268071
no-mistakes(review): Preserve tool edits and auto failover after lega…
i098 Nov 3, 2026
6d0674f
no-mistakes(review): Keep auto effort on shared limiter during activa…
i098 Nov 3, 2026
6b04c48
no-mistakes(review): Serialize profile saves with provider resolution
i098 Nov 3, 2026
90240a6
no-mistakes(review): Keep bound, chat, and activation routing atomic
i098 Nov 3, 2026
505e900
no-mistakes(review): Fix provider reapply races and task target guidance
i098 Nov 3, 2026
1d8ab28
no-mistakes(review): Require target names and stabilize provider and …
i098 Nov 3, 2026
6088ed2
no-mistakes(review): Preserve profile limiters and durable task context
i098 Nov 3, 2026
96d87f9
no-mistakes(review): Preserve shared rate limits across retry policy …
i098 Nov 3, 2026
2212539
no-mistakes(review): Preserve tool edits and reject invalid profile s…
i098 Nov 3, 2026
72c0d03
no-mistakes(review): Fix task fixture, spawn schema, and profile routing
i098 Nov 3, 2026
c60975c
no-mistakes(review): Seed goal targets and safely refresh shared prov…
i098 Nov 3, 2026
a4cfb22
no-mistakes(review): Rebuild applied LLM settings and report running …
i098 Nov 3, 2026
72115d9
no-mistakes(review): Preserve applied pool metadata and queued auto p…
i098 Nov 3, 2026
17b3704
no-mistakes(review): Honor applied LLM state and optional task names
i098 Nov 3, 2026
bd7615f
no-mistakes(review): Preserve applied providers and label unnamed tasks
i098 Nov 3, 2026
4c28702
web: satisfy biome check (format + import order) from review fix rounds
i098 Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
# Changelog
## [Unreleased] - 2026-11-03

- Task thinking effort is selectable in task creation, the iMessage `create_task`
tool, and `spawn_task`. `auto` uses each model's provider default; `max` keeps
each selected profile's configured effort. Existing tasks keep their profile
settings.
- iMessage bridge gains `rename_task` (via `PATCH /api/tasks/{id}`), and
`create_task` now spells out that `name` is the display title and `description`
is long-form context, so names stop landing in descriptions.
- Asset graph: legend and labels are in English, and colors follow the app theme
instead of staying light inside dark mode.
- LLM Token Usage panel defaults to the New view; Legacy remains selectable.


## [Unreleased] - 2026-10-31

Expand Down
54 changes: 53 additions & 1 deletion agent/blackboard_inheritance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import (
"testing"
"time"

"pta/db"
actool "normasdk/tool"
"pta/db"
)

func callReadJSON(t *testing.T, tool actool.CoreTool, input string) any {
Expand All @@ -25,6 +25,58 @@ func callReadJSON(t *testing.T, tool actool.CoreTool, input string) any {
return out
}

func TestGraphOverviewKeepsCreationTargetAfterRename(t *testing.T) {
d := testDB(t)
defer d.Close()

const originalName = "10.0.0.1:8080 audit"
task, err := d.CreateTaskWithOptions("", "Scan target", db.TaskCreateOptions{Name: originalName})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })

renamed := "other.com audit"
if _, err := d.UpdateTask(task.ID, db.TaskPatch{Name: &renamed}); err != nil {
t.Fatal(err)
}

store := d.Exploration(task.ExplorationID)
origin, err := store.OriginFactID()
if err != nil {
t.Fatal(err)
}
node, err := store.GetNode(origin)
if err != nil || node == nil {
t.Fatalf("origin node: %v", err)
}
var payload struct {
Summary string `json:"summary"`
}
if err := json.Unmarshal(node.Payload, &payload); err != nil {
t.Fatal(err)
}
if !strings.Contains(payload.Summary, originalName) || strings.Contains(payload.Summary, renamed) {
t.Fatalf("origin summary lost creation target: %q", payload.Summary)
}

tools := NewToolSet(store, "planner")
overview, ok := callReadJSON(t, tools.GraphOverviewTool(), "{}").(map[string]any)
if !ok {
t.Fatal("graph overview is not an object")
}
context, ok := overview["task"].(map[string]any)
if !ok {
t.Fatal("task context missing")
}
if context["creation_name"] != originalName || context["goal"] != "Scan target" {
t.Fatalf("wrong task context: %#v", context)
}
if _, hasDescription := context["description"]; hasDescription {
t.Fatalf("blank description included in task context: %#v", context)
}
}

func TestGraphOverviewExpandsAssociatedCompanyScope(t *testing.T) {
d := testDB(t)
defer d.Close()
Expand Down
18 changes: 16 additions & 2 deletions agent/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import (
"strconv"
"strings"

"pta/db"
acperm "normasdk/permission"
actool "normasdk/tool"
"pta/db"
)

// compactIntents distills intents to {id, summary, state, asset_ids, parents,
Expand Down Expand Up @@ -510,7 +510,21 @@ func (t *ToolSet) graphOverviewData() map[string]any {
// the original task (root) so the planner always has it, not just the
// decomposed goals.
if description, goal, err := t.ts.Root(); err == nil {
out["task"] = map[string]any{"description": description, "goal": goal}
task := map[string]any{"goal": goal}
if origin, err := t.ts.OriginFactID(); err == nil && origin > 0 {
if node, err := t.ts.GetNode(origin); err == nil && node != nil {
var payload struct {
CreationName string `json:"creation_name"`
}
if json.Unmarshal(node.Payload, &payload) == nil && payload.CreationName != "" {
task["creation_name"] = payload.CreationName
}
}
}
if description != "" {
task["description"] = description
}
out["task"] = task
}
// Direct source tasks are a live, read-only blackboard view. Keep their
// summaries in a separate field so their intents never enter this task's
Expand Down
43 changes: 34 additions & 9 deletions bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,26 +181,29 @@ const TOOLS: Record<string, ToolDef> = {
},
},
create_task: {
description: "Create a new jcyber task targeting a domain/host. Each target gets its own task. Include a clear goal and optional LLM profile id (1=DeepSeek Max, 5-10=Mercury 3 high). Returns the new task id.",
description: "Create a new jcyber task targeting a domain/host. Each target gets its own task. Optional name is the short display title; description is separate long-form context, never the task name. Include the target in name or goal. Set thinking_effort to auto for easy jobs or max to keep the selected profile's configured effort for hard ones. Returns the new task id.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Short task name, e.g. 'example.com scan'" },
name: { type: "string", description: "Optional task display name/title, e.g. 'example.com scan'. Put the task name here, not in description." },
goal: { type: "string", description: "What to do against the target" },
description: { type: "string", description: "Task description (defaults to Untitled Task)" },
llm_profile_id: { type: "number", description: "Optional LLM profile id (1=DeepSeek Max, 5-10=Mercury 3)" },
description: { type: "string", description: "Optional long-form task context/instructions, separate from the display name." },
llm_profile_id: { type: "number", description: "Optional explicit LLM profile id." },
thinking_effort: { type: "string", enum: ["max", "auto"], description: "Reasoning effort for this task: omit or 'max' to use the selected profile's configured effort, 'auto' to use the provider default." },
},
required: ["goal"],
},
run: async (a) => {
const name = str(a.name).trim();
const res = await fetch(`${JCYBER_URL}/api/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${await auth()}` },
body: JSON.stringify({
name: str(a.name),
name,
goal: str(a.goal),
description: str(a.description) || "Untitled Task",
description: str(a.description),
llm_profile_id: Number(a.llm_profile_id) > 0 ? Number(a.llm_profile_id) : undefined,
thinking_effort: a.thinking_effort,
seed_first_intent: true,
}),
signal: AbortSignal.timeout(15_000),
Expand All @@ -210,6 +213,28 @@ const TOOLS: Record<string, ToolDef> = {
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"] },
Expand Down Expand Up @@ -270,9 +295,9 @@ const toolSpecs = (): ChatCompletionTool[] =>
async function subagent(prompt: string): Promise<string> {
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.` },
Expand Down
1 change: 1 addition & 0 deletions db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion db/task_archives_restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions db/task_archives_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
30 changes: 21 additions & 9 deletions db/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -149,6 +150,7 @@ type TaskCreateOptions struct {
SourceTaskIDs []int64
CompanyIDs []int64
LLMProfileIDs []int64
ThinkingEffort string
TimeoutSeconds int
PlanHeartbeatSeconds int
// CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建
Expand Down Expand Up @@ -179,14 +181,23 @@ func (d *DB) CreateTaskWithOptions(description, goal string, opts TaskCreateOpti
return nil, err
}
// origin fact: the exploration graph's root, a KindFact node (state='origin')
// holding the original task description. Goals/intents/findings descend from
// holding the creation name, description, and goal. Goals/intents/findings descend from
// it, and the seeded target asset is anchored to it as lineage (the asset graph
// is global and shared, not isolated per task). Being a fact (not a special 'begin' kind) lets every
// intent uniformly connect to a fact node, including the first ones.
summary := "Task starting point"
if opts.Name != "" {
summary += ": " + opts.Name
}
if description != "" {
summary += "; description: " + description
}
summary += "; goal: " + goal
originPayload, _ := json.Marshal(map[string]any{
"summary": "Task starting point: " + description + "; goal: " + goal,
"description": description,
"goal": goal,
"summary": summary,
"creation_name": opts.Name,
"description": description,
"goal": goal,
})
if _, err := tx.Exec(`
INSERT INTO exploration_nodes(exploration_id, kind, payload, priority, state, origin)
Expand Down Expand Up @@ -219,16 +230,17 @@ 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...),
TimeoutSeconds: opts.TimeoutSeconds, PlanHeartbeatSeconds: opts.PlanHeartbeatSeconds,
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 {
Expand Down Expand Up @@ -344,11 +356,11 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {

const taskCols = `id, COALESCE(name,''), category_id,
COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''),
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(thinking_effort,''), COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at`

func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
var t Task
if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ThinkingEffort, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
return nil, err
}
t.Pinned = t.PinnedAt != nil
Expand Down
Loading
Loading