Files
artex/server/orchestration.go
T
dela 97668dab7e
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
docs / links (push) Waiting to run
web / web (push) Waiting to run
Localize user-facing UI, agent output, and backend messages to Simplified Chinese
2026-10-09 14:08:31 +08:00

930 lines
45 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"context"
"encoding/json"
"fmt"
"log"
"strconv"
"strings"
"time"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool"
)
// jsonResult marshals v to a JSON tool result.
func jsonResult(v any) (actool.Result, error) {
b, err := json.Marshal(v)
if err != nil {
return actool.Errorf(err.Error()), nil
}
return actool.Text(string(b)), nil
}
// 本文件实现 P2「跨任务编排工具集」(docs/跑分编排 §2 P2)。这些是 host 工具——需要
// 访问 Manager(任意任务的 Store)、Engine(暂停)、以及建任务流程,所以住在 server 层。
// 读类工具把「现有 per-task 工具」重定向到目标任务的 store 上跑(建一个临时 ToolSet
// 并 Call 其对应工具),从而复用完全相同的逻辑;控制类(spawn/pause)直接调 Manager/Engine。
// 它们像流量工具一样 seed 进 tools 表、按 agent 绑定(只绑给编排 agent 才可见)。
// hostTools is the runtime host-tool provider fed to ToolAugment: traffic tools
// (gated by capture) + cross-task orchestration tools + user-defined custom tools.
// The second return is the names of custom tools flagged `deferred` (schema
// withheld, routed via SearchExtraTools/ExecuteExtraTool). Per-agent binding still
// decides who actually sees any of them.
//
//nolint:unused // used as the hostTools provider in wireAgentAugment
func (s *Server) hostTools() ([]actool.CoreTool, map[string][]string) {
tools := append(s.m.HostTools(), s.orchestrationTools()...)
tools = append(tools, s.findingRetestTools()...)
tools = append(tools, s.platformTools()...) // 平台操作工具(建改 skill/工具/MCP,给 Auto 用)
custom, err := s.customTools()
if err != nil {
log.Printf("[custom-tool] 加载失败: %v", err)
return tools, nil
}
tools = append(tools, custom...)
// deferred custom tools → name -> its bound agent keys. ToolAugment turns a
// name into a deferred entry only for agents it's actually bound to (so we don't
// advertise a tool the per-agent binding will drop from the callable set).
deferred := map[string][]string{}
rows, _ := s.m.pg.ListCustomTools()
for _, t := range rows {
if t.Deferred && t.Enabled {
deferred[t.Key] = t.Agents
}
}
return tools, deferred
}
// orchestrationTools returns the cross-task tool set. Bound per-agent via the
// tools table (default: no binding — opt-in for orchestration agents).
func (s *Server) orchestrationTools() []actool.CoreTool {
return []actool.CoreTool{
s.toolListTasks(),
s.toolListLLMProfiles(),
s.toolSpawnTask(),
s.toolPauseTask(),
s.toolGetTaskGraph(),
s.toolListTaskFindings(),
s.toolAddHint(),
s.toolGetWorkerTrace(),
s.toolListWorkerTraces(),
s.toolSearchWorkerTraces(),
s.toolGetTaskNodeDetail(),
s.toolUpdateFindingReport(),
s.toolGetFindingTraffic(),
s.toolBindFindingTraffic(),
}
}
// --- schema helpers ---
func strParam(desc string) map[string]any {
return map[string]any{"type": "string", "description": desc}
}
// parseProfileID reads an LLM profile id from a tool arg that may arrive as a JSON
// number (5) or a numeric string ("5"); returns 0 when absent/unparseable.
func parseProfileID(raw json.RawMessage) int64 {
if len(raw) == 0 {
return 0
}
var n int64
if json.Unmarshal(raw, &n) == nil {
return n
}
var str string
if json.Unmarshal(raw, &str) == nil {
v, _ := strconv.ParseInt(strings.TrimSpace(str), 10, 64)
return v
}
return 0
}
func objSchema(props map[string]any, required ...string) map[string]any {
m := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
req := make([]any, len(required))
for i, r := range required {
req[i] = r
}
m["required"] = req
}
return m
}
func roTool(name, desc string, schema map[string]any, run func(context.Context, json.RawMessage) (actool.Result, error)) actool.CoreTool {
return actool.Build(actool.Spec{
Name: name, Description: desc, Schema: schema,
ReadOnly: func(json.RawMessage) bool { return true },
Concurrent: func(json.RawMessage) bool { return true },
Permissions: func(context.Context, json.RawMessage, permission.Context) permission.Decision {
return permission.Allowed()
},
Run: func(ctx context.Context, in json.RawMessage, _ *actool.ToolContext) (actool.Result, error) {
return run(ctx, in)
},
})
}
func wrTool(name, desc string, schema map[string]any, run func(context.Context, json.RawMessage) (actool.Result, error)) actool.CoreTool {
return actool.Build(actool.Spec{
Name: name, Description: desc, Schema: schema,
Permissions: func(context.Context, json.RawMessage, permission.Context) permission.Decision {
return permission.Allowed()
},
Run: func(ctx context.Context, in json.RawMessage, _ *actool.ToolContext) (actool.Result, error) {
return run(ctx, in)
},
})
}
// delegateToTask resolves the `task_id` in the input, builds a ToolSet bound to
// that task's store, strips task_id, and calls the chosen per-task tool — so the
// cross-task read reuses the exact in-task logic against another task.
func (s *Server) delegateToTask(ctx context.Context, in json.RawMessage, pick func(*agent.ToolSet) actool.CoreTool) (actool.Result, error) {
var head struct {
TaskID string `json:"task_id"`
}
_ = json.Unmarshal(in, &head)
if strings.TrimSpace(head.TaskID) == "" {
return actool.Errorf("task_id 为必填"), nil
}
t, ok := s.m.Task(head.TaskID)
if !ok {
return actool.Errorf("task 不存在: " + head.TaskID), nil
}
var m map[string]json.RawMessage
_ = json.Unmarshal(in, &m)
delete(m, "task_id")
inner, _ := json.Marshal(m)
tsx := agent.NewToolSet(t.Store, "orchestrator")
if s.m.Assets() != nil {
tsx.SetAssetStore(s.m.Assets(), s.m.Assets().Companies())
}
tsx.SetNotify(t.Notify) // 通用唤醒(无专用回调的写操作走它;读工具为 no-op)
tsx.SetNotifyHint(t.NotifyHint) // add_hint → 记一条「人新增了 N 条战略提示:…」触发并唤醒 planner
return pick(tsx).Call(ctx, inner, nil)
}
// --- tools ---
func (s *Server) toolListTasks() actool.CoreTool {
return roTool("list_tasks",
"列出所有任务(id/描述/目标/状态/运行时长/父任务/LLM 配置),编排 agent 用它掌握全局、看哪些任务卡太久、各自用哪个 LLM。运行时长:运行中=创建→现在,终态=创建→最后活动(秒)。llm_profile:任务 planner/worker 用的配置名,(激活配置)=跟随全局激活。",
objSchema(map[string]any{}),
func(context.Context, json.RawMessage) (actool.Result, error) {
lastAct, _ := s.m.PG().LastActivityAll()
// id -> name to resolve each task's pinned LLM profile.
profName := map[int64]string{}
if profs, err := s.m.pg.ListProfiles(); err == nil {
for _, p := range profs {
profName[p.ID] = p.Name
}
}
out := make([]map[string]any, 0)
for _, t := range s.m.List() {
status := s.deriveTaskStatus(t)
end := lastAct[t.ExpID]
if live := s.engine.LastActivity(t.ID); live > end {
end = live
}
dur := int64(0)
if status == "running" {
dur = time.Now().Unix() - t.CreatedAt
} else if end > t.CreatedAt {
dur = end - t.CreatedAt
}
row := map[string]any{"id": t.ID, "description": t.Description, "goal": t.Goal, "status": status, "run_seconds": dur}
if t.ParentRef != "" {
row["parent_ref"] = t.ParentRef
}
llmState := t.llmStateSnapshot()
if llmState.ProfileID == nil {
row["llm_profile"] = "(激活配置)"
} else if n, ok := profName[*llmState.ProfileID]; ok {
row["llm_profile"] = n
} else {
row["llm_profile"] = fmt.Sprintf("#%d(已删除)", *llmState.ProfileID)
}
out = append(out, row)
}
return jsonResult(out)
})
}
// toolListLLMProfiles lists the available LLM profiles (name/model/active) so an
// orchestration agent can pick one for spawn_task's llm_profile. Never leaks keys.
func (s *Server) toolListLLMProfiles() actool.CoreTool {
return roTool("list_llm_profiles",
"列出可用的 LLM 配置(profile):id、名称、模型、格式、是否为当前激活配置。用 id 给 spawn_task 的 llm_profile_id 参数指定子任务专属 LLM(如侦察用便宜模型、利用用强模型)。不含 API Key。",
objSchema(map[string]any{}),
func(context.Context, json.RawMessage) (actool.Result, error) {
profs, err := s.m.pg.ListProfiles()
if err != nil {
return actool.Errorf(err.Error()), nil
}
out := make([]map[string]any, 0, len(profs))
for _, p := range profs {
out = append(out, map[string]any{
"id": p.ID, "name": p.Name, "model": p.Model, "format": p.Format, "is_active": p.IsDefault,
})
}
return jsonResult(map[string]any{"profiles": out})
})
}
func (s *Server) toolSpawnTask() actool.CoreTool {
return wrTool("spawn_task",
"新建一个子任务并启动探索引擎,返回 task_id。用于把一件事(如一道题/一个目标)派成独立任务。parent_ref 可选:填当前编排关联的父任务 id 做父子关联。",
objSchema(map[string]any{
"description": strParam("任务描述(简短标题)"),
"goal": strParam("任务目标(要达成什么)"),
"parent_ref": strParam("可选:父任务 id(做父子关联)"),
"source_task_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": fmt.Sprintf("可选:只读继承的来源任务 id 列表(最多 %d 个)。子任务可只读引用这些任务已探明的资产/结论作为起点;与 parent_ref 的纯父子指针不同,这是内容继承。", db.MaxTaskSourceCount)},
"llm_profile_id": map[string]any{"type": "integer", "description": "可选:指定本子任务 planner/worker 用的 LLM 配置 id(见 list_llm_profiles);留空则继承父任务、再回退全局激活配置"},
"timeout_seconds": map[string]any{"type": "integer", "description": "可选:任务级超时(秒)。到点后触发优雅收尾并进入 timeout 终态;留空或 0 = 不限时"},
"plan_heartbeat_seconds": map[string]any{"type": "integer", "description": "可选:planner 心跳触发间隔(秒)。距上轮规划结束/任务开始满该值且期间无触发 → 触发一轮规划(兜底死锁 + 唤醒去监督飞行中的 worker)。留空或 0 = 默认 600(10min);"},
"seed_first_intent": map[string]any{"type": "boolean", "description": "可选:对于简单任务可开启,创建时直接下发一条种子意图(内容=描述+目标)让 worker 免等首轮 planner 直接开跑测试;默认 false(走标准先规划再执行)。"},
}, "description", "goal"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
Description string `json:"description"`
Goal string `json:"goal"`
ParentRef string `json:"parent_ref"`
SourceTaskIDs []string `json:"source_task_ids"`
LLMProfileID json.RawMessage `json:"llm_profile_id"`
TimeoutSeconds int `json:"timeout_seconds"`
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"`
SeedFirstIntent bool `json:"seed_first_intent"`
}
_ = json.Unmarshal(in, &a)
if strings.TrimSpace(a.Description) == "" {
a.Description = "未命名任务"
}
if strings.TrimSpace(a.Goal) == "" {
return actool.Errorf("goal 为必填"), nil
}
if a.TimeoutSeconds < 0 {
a.TimeoutSeconds = 0
}
// 只读继承来源任务:数量上限 + 每个 id 有效/去重/存在,校验规则与 HTTP 建任务一致。
if len(a.SourceTaskIDs) > db.MaxTaskSourceCount {
return actool.Errorf(fmt.Sprintf("关联任务最多选择 %d 个", db.MaxTaskSourceCount)), nil
}
sourceIDs := make([]int64, 0, len(a.SourceTaskIDs))
seenSources := map[int64]bool{}
for _, raw := range a.SourceTaskIDs {
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || id <= 0 || seenSources[id] {
return actool.Errorf("关联任务 id 无效或重复"), nil
}
if _, ok := s.m.Task(strconv.FormatInt(id, 10)); !ok {
return actool.Errorf(fmt.Sprintf("关联任务 #%d 不存在", id)), nil
}
seenSources[id] = true
sourceIDs = append(sourceIDs, id)
}
// LLM profile resolution: explicit id > inherit parent's pin > active(nil).
var pin *int64
if id := parseProfileID(a.LLMProfileID); id > 0 {
if _, ok := s.loadProfileConfig(id); !ok {
return actool.Errorf(fmt.Sprintf("LLM 配置 #%d 不存在或未设置 API Key", id)), nil
}
pin = &id
} else if a.ParentRef != "" {
if pt, ok := s.m.Task(a.ParentRef); ok {
pin = pt.LLMProfileID
}
}
var llmIDs []int64
if pin != nil {
llmIDs = []int64{*pin}
}
t, err := s.m.CreateTaskWithOptions(a.Description, a.Goal, db.TaskCreateOptions{
SourceTaskIDs: sourceIDs,
LLMProfileIDs: llmIDs,
TimeoutSeconds: a.TimeoutSeconds,
PlanHeartbeatSeconds: a.PlanHeartbeatSeconds,
})
if err != nil {
return actool.Errorf(err.Error()), nil
}
if a.ParentRef != "" {
t.ParentRef = a.ParentRef
if id, e := strconv.ParseInt(t.ID, 10, 64); e == nil {
_ = s.m.PG().SetParentRef(id, a.ParentRef)
}
}
// 共享的建后流程,与 HTTP 建任务(server.go createTask)复用同一段 launchTask:
// seed + 后台可见地做目标分解(第0轮/LLM步骤/逐条goal) + engine.Run。
// seed_first_intent 默认 false(标准先规划再执行);简单任务可开启直接下发一 work 测试。
s.launchTask(t, a.Description+" "+a.Goal, a.SeedFirstIntent)
return actool.Text(fmt.Sprintf("task created: %s", t.ID)), nil
})
}
func (s *Server) toolPauseTask() actool.CoreTool {
return wrTool("pause_task", "暂停指定任务(停止其 planner/worker 循环)。",
objSchema(map[string]any{"task_id": strParam("要暂停的任务 id")}, "task_id"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
TaskID string `json:"task_id"`
}
_ = json.Unmarshal(in, &a)
t, ok := s.m.Task(a.TaskID)
if !ok {
return actool.Errorf("task 不存在: " + a.TaskID), nil
}
if _, err := s.applyTaskControlWithCause(t, "pause", agent.AbortPausedByOrchestrator); err != nil {
return actool.Errorf(err.Error()), nil
}
return actool.Text("task paused: " + a.TaskID), nil
})
}
func (s *Server) toolGetTaskGraph() actool.CoreTool {
return roTool("get_task_graph", "读指定任务的探索图总览(同 graph_overview:资产计数/frontier/发现/覆盖等),用 task_id 指定任务。",
objSchema(map[string]any{"task_id": strParam("任务 id")}, "task_id"),
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
return s.delegateToTask(ctx, in, (*agent.ToolSet).GraphOverviewTool)
})
}
func (s *Server) toolListTaskFindings() actool.CoreTool {
return roTool("list_task_findings", "读指定任务的确认漏洞(含 flag/PoC;每条带 id/task_id/intent_id/vulnclass/severity/摘要/状态),用 task_id 指定任务。",
objSchema(map[string]any{"task_id": strParam("任务 id")}, "task_id"),
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
return s.delegateToTask(ctx, in, (*agent.ToolSet).ListFindingsTool)
})
}
func (s *Server) toolAddHint() actool.CoreTool {
return wrTool("add_task_hint", "给指定任务注入战略提示(该任务的 planner 下轮生成意图时会读到)。\n"+
"★优先批量:多条提示放进 hints 数组一次提交(返回 ids 数组,与 hints 等长同序,失败项 id=0);单条则省略 hints 直接给顶层 text。",
objSchema(map[string]any{
"task_id": strParam("任务 id"),
"hints": map[string]any{"type": "array", "description": "【优先用这个】提示数组,每个元素字段同顶层(text/asset_ids/traffic_refs)。", "items": objSchema(map[string]any{"text": strParam("提示内容"), "asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}}, "traffic_refs": agent.HintTrafficSchema()})},
"text": strParam("[单条] 提示内容"),
"traffic_refs": agent.HintTrafficSchema(),
"asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "锚定的资产 id(可选,0/1/多个;该任务内的资产 id)"},
}, "task_id"),
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
return s.delegateToTask(ctx, in, (*agent.ToolSet).AddHintTool)
})
}
func (s *Server) toolGetWorkerTrace() actool.CoreTool {
return roTool("get_task_worker_trace",
"看指定任务里某个 work(意图)的执行过程:get_task_worker_trace(task_id, intent_id) 看步骤摘要;再带 step_ids=[...] 取那几步完整内容(一次最多 5 个,多传只返回前 5 个)。",
objSchema(map[string]any{
"task_id": strParam("任务 id"),
"intent_id": map[string]any{"type": "integer", "description": "意图 id(该任务里的 work)"},
"step_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "可选:要取完整内容的步骤 id(一次最多 5 个,多传只返回前 5 个,其余在 omitted_step_ids 里列出)"},
}, "task_id", "intent_id"),
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
return s.delegateToTask(ctx, in, (*agent.ToolSet).GetWorkerTraceTool)
})
}
func (s *Server) toolListWorkerTraces() actool.CoreTool {
return roTool("list_task_worker_traces", "列出指定任务里跑过哪些 work(意图) + 各自步数,用于发现哪些 work 值得翻看(再用 get_task_worker_trace)。",
objSchema(map[string]any{"task_id": strParam("任务 id")}, "task_id"),
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
return s.delegateToTask(ctx, in, (*agent.ToolSet).ListWorkerTracesTool)
})
}
func (s *Server) toolSearchWorkerTraces() actool.CoreTool {
return roTool("search_task_worker_traces", "在指定任务里按关键字搜索所有 work 的执行过程(返回命中步骤摘要 + intent_id)。",
objSchema(map[string]any{"task_id": strParam("任务 id"), "q": strParam("搜索关键字")}, "task_id", "q"),
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
return s.delegateToTask(ctx, in, (*agent.ToolSet).SearchWorkerTracesTool)
})
}
func (s *Server) toolGetTaskNodeDetail() actool.CoreTool {
return roTool("get_task_node_detail",
"读指定任务里某个探索图节点的完整内容(发现/事实/意图/目标:摘要 + 详情/证据/PoC)。id 为探索节点 id(如 report_finding 返回、或 list_task_findings 里的 id)。写漏洞报告前用它取该漏洞的完整证据。",
objSchema(map[string]any{
"task_id": strParam("任务 id"),
"id": map[string]any{"type": "integer", "description": "探索图节点 id(非资产 id)"},
}, "task_id", "id"),
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
return s.delegateToTask(ctx, in, (*agent.ToolSet).NodeDetailTool)
})
}
// toolUpdateFindingReport writes/overwrites a finding's detailed Markdown report.
// finding_id is the id report_finding returned ("finding recorded: <id>", the
// finding node id). The write (SetFindingReportByNodeID) is keyed by node_id and
// task-agnostic, so this host tool needs no task_id / exploration store.
func (s *Server) toolUpdateFindingReport() actool.CoreTool {
return wrTool("update_finding_report",
"为已登记的漏洞写入/更新【详细报告】(Markdown 全文,整段覆盖旧内容)。finding_id 传 report_finding 返回的那个 id(\"finding recorded: <id>\" 里的数字)。报告建议包含:漏洞概述、影响与危害、复现步骤、证据/PoC、修复建议。",
objSchema(map[string]any{
"finding_id": map[string]any{"type": "integer", "description": "目标漏洞 id(report_finding 返回的 id)"},
"report": strParam("详细报告全文,Markdown 格式"),
"evidence_version": map[string]any{"type": "integer", "description": "get_finding_traffic 返回的证据 version;用于防止报告覆盖新的证据变更"},
}, "finding_id", "report"),
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
EvidenceVersion *int64 `json:"evidence_version"`
FindingID json.RawMessage `json:"finding_id"`
Report string `json:"report"`
}
_ = json.Unmarshal(in, &a)
nodeID := parseProfileID(a.FindingID) // 复用「数字或数字字符串」解析
if nodeID <= 0 {
return actool.Errorf("finding_id 无效"), nil
}
n, err := s.m.pg.SetFindingReportVersionByNodeID(ctx, nodeID, a.Report, a.EvidenceVersion)
if err != nil {
return actool.Errorf(err.Error()), nil
}
if n == 0 {
return actool.Errorf(fmt.Sprintf("未找到 finding_id=%d 对应的漏洞记录(先用 report_finding 登记)", nodeID)), nil
}
return actool.Text(fmt.Sprintf("finding %d report updated (%d chars)", nodeID, len(a.Report))), nil
})
}
// deriveTaskStatus mirrors listTasks' status derivation for the list_tasks tool.
func (s *Server) deriveTaskStatus(t *Task) string {
lifecycle := t.lifecycleSnapshot()
switch {
case isTerminalStatus(lifecycle.Status):
return lifecycle.Status
case lifecycle.Paused || s.engine.IsPaused(t.ID):
return "paused"
case s.engine.ReadyFor(t) && s.engine.Started(t.ID):
return "running"
}
return "created"
}
// orchestrationToolSeeds seeds the cross-task tools into the tools table so they
// are bindable per-agent (default: bound to nobody — opt-in for orchestration
// agents). First-insert only, like the traffic seeds.
func (s *Server) seedOrchestrationTools() {
// task-op + platform tools default-bind to the built-in Auto agent (它天生用来
// 操作平台)。SeedTool 首插入生效;老库已 seed 的行由 seedAutoDefaultBindings 补绑。
autoAgents, _ := json.Marshal([]string{"auto"})
for _, t := range s.orchestrationTools() {
schema, _ := json.Marshal(t.InputSchema())
bindings := autoAgents
if t.Name() == "bind_finding_traffic" {
bindings = json.RawMessage(`["reporter"]`)
}
_ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, bindings)
}
for _, t := range s.platformTools() {
schema, _ := json.Marshal(t.InputSchema())
_ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, autoAgents)
}
s.refreshBuiltinToolSchemas()
s.seedAutoDefaultBindings()
s.seedPlannerDefaultBindings()
s.seedPlannerListAssetsBinding()
s.seedCompanyScopeRebind()
s.seedWorkerReadToolsUnbind() // list_facts/list_companies/list_worker_traces 从 worker 默认解绑(一次性)
s.seedWorkerReadbackRebind() // 修复旧迁移误删:把 search_all_worker_traces/get_worker_trace/node_detail 补绑回 worker(一次性)
s.seedAutoReportFindingBinding()
s.unbindGoalMetDefault()
s.reseedGoalsPrompt() // goals 提示词加入「抽操作约束」步 → 旧库追加一版新默认(一次性)
s.reseedMainAgentPrompt() // mainagent 提示词加入「目标达成后 add_intent 反问是否建目标」(一次性)
s.reseedPlannerPrompt() // planner 提示词:重写「0 意图」正当理由 + 加量化验收核对(一次性)
s.reseedWorkerPrompt() // worker 提示词:加否定结论证据门槛(一次性)
s.seedReporterAgent() // 预置「보고서 작성」agent + 工具绑定 + finding 触发器(一次性)
s.upgradeReporterTriggerMessage() // 老库补迁移:让 reporter 回传 evidence_version(一次性)
s.seedFindingTrafficTools() // 增加可选证据参数及只读证据工具,保留用户配置
s.seedFindingWorkflowTools()
// 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把
// list_assets/insert_assets/report_finding/list_findings/list_companies 连同
// pentest 一起 seed 好了(项目尚无旧库,不做迁移)。
}
// refreshBuiltinToolSchemas propagates code schema/description changes on the
// orchestration + platform tools into already-seeded rows ONCE per version flag —
// SeedTool is first-insert-only, so a new param (e.g. spawn_task 的 llm_profile) never
// reaches an old DB otherwise. Preserves each tool's agent binding + enabled flag.
// Bump the flag whenever these tools' schemas/descriptions change in code.
func (s *Server) refreshBuiltinToolSchemas() {
const flag = "tool_schema_refresh_v7_list_facts_paging"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
tools := append(s.orchestrationTools(), s.platformTools()...)
for _, t := range tools {
schema, _ := json.Marshal(t.InputSchema())
if err := s.m.pg.RefreshToolDefaults(t.Name(), t.Description(), schema); err != nil {
log.Printf("[tools] refresh %s schema failed: %v", t.Name(), err)
}
}
// 同时把部分内置 agent 工具刷成代码默认:
// - goal_met:旧库 seed 的描述带“结束本轮规划”的误导,会让 planner 把它当成
// “结束空轮”的手段、刚开跑就误判整个任务完成。
// - insert_assets:新增 related 入参(标记资产是否与当前任务相关、决定是否入覆盖度),
// SeedTool 首插入only,旧库已 seed 的 schema 否则收不到这个新参数。
// - list_facts:改为分页,新增 limit/before/q 入参;旧库已 seed 的空 schema 否则
// 在工具管理页显示「无参数」,模型也拿不到这几个参数说明。
refreshBuiltin := map[string]bool{"goal_met": true, "insert_assets": true, "list_facts": true}
for _, sd := range agent.BuiltinToolSeeds() {
if !refreshBuiltin[sd.Key] {
continue
}
schema, _ := json.Marshal(sd.Schema)
if err := s.m.pg.RefreshToolDefaults(sd.Key, sd.Desc, schema); err != nil {
log.Printf("[tools] refresh %s desc failed: %v", sd.Key, err)
}
}
_ = s.m.pg.SetSetting(flag, "true")
log.Printf("[tools] 已将 orchestration/platform 工具 schema 刷新为代码默认值(一次性)")
}
// unbindGoalMetDefault removes goal_met's default "planner" binding ONCE (guarded by
// a settings flag), so existing DBs match the new default of NO agent. goal_met bypasses
// per-goal prove_goal to declare the whole task done — powerful/risky and redundant with
// the prove_goal→auto-complete path — so it ships unbound; users can re-bind it per agent
// in the UI. A user's own binding to another agent is untouched (we only strip planner).
func (s *Server) unbindGoalMetDefault() {
const flag = "goal_met_unbind_default_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
if err := s.m.pg.RemoveAgentFromTool("planner", "goal_met"); err != nil {
log.Printf("[tools] goal_met 解绑 planner 失败: %v", err)
return
}
_ = s.m.pg.SetSetting(flag, "true")
}
// reseedGoalsPrompt 把 goals 目标拆解器的提示词刷成【当前代码默认】——因为默认正文新增了
// 「先抽操作约束(set_constraints)再拆目标」这一步,而 SeedPromptIfEmpty 首插入only,旧库
// 已有的 version 1 收不到这步。这里用版本管理【追加一个新版本】并切过去(ResetPromptToDefault),
// 旧的版本仍保留在历史里,用户若自定义过可从版本记录找回。settings flag 守卫 → 只做一次;
// 以后默认再变就 bump 这个 flag。全新库无需处理(SeedPromptIfEmpty 已 seed 最新默认)。
func (s *Server) reseedGoalsPrompt() {
const flag = "goals_prompt_constraint_step_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
a, err := s.m.pg.GetAgentByKey("goals")
if err != nil || a == nil {
return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移
}
tmpl := agent.BuiltinPromptSeeds()["goals"]
if tmpl == "" {
return
}
// 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。
if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl {
return
}
if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil {
log.Printf("[prompts] goals 提示词重刷为新默认失败: %v", err)
return
}
log.Printf("[prompts] goals 提示词已追加新默认版本(加入抽操作约束步,一次性)")
}
// reseedMainAgentPrompt 把 mainagent 提示词刷成【当前代码默认】——默认正文新增了「目标全部
// 达成后 add_intent 直投意图时,反问人是否登记为正式目标」这段引导,而 SeedPromptIfEmpty 首插入
// only,旧库已有版本收不到。用版本管理【追加一个新版本】并切过去(ResetPromptToDefault),旧版本仍
// 保留在历史里,用户若自定义过可从版本记录找回。settings flag 守卫 → 只做一次。全新库无需处理
// (SeedPromptIfEmpty 已 seed 最新默认)。与 reseedGoalsPrompt 完全同构。
func (s *Server) reseedMainAgentPrompt() {
const flag = "mainagent_prompt_goalless_intent_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
a, err := s.m.pg.GetAgentByKey("mainagent")
if err != nil || a == nil {
return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移
}
tmpl := agent.BuiltinPromptSeeds()["mainagent"]
if tmpl == "" {
return
}
// 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。
if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl {
return
}
if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil {
log.Printf("[prompts] mainagent 提示词重刷为新默认失败: %v", err)
return
}
log.Printf("[prompts] mainagent 提示词已追加新默认版本(加入目标达成后反问建目标,一次性)")
}
// reseedPlannerPrompt 把 planner 提示词刷成【当前代码默认】——默认正文做了精简重构,并把「克制」降级为
// 仅去重、新增「深度优先于覆盖度」「硬底线:目标未达成且无在跑意图必须产出」、给否定结论复核加上界。
// 每次默认有实质变更就 bump 下面的 flag(当前 v2)让存量旧库再刷一次。SeedPromptIfEmpty 首插入only,旧库已有版本收不到,故用版本管理
// 【追加一个新版本】并切过去(ResetPromptToDefault),旧版本仍保留在历史里,用户若自定义过可从版本记录
// 找回。settings flag 守卫 → 只做一次。全新库无需处理(SeedPromptIfEmpty 已 seed 最新默认)。与
// reseedGoalsPrompt 完全同构。
func (s *Server) reseedPlannerPrompt() {
const flag = "planner_prompt_compact_realistic_v2"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
a, err := s.m.pg.GetAgentByKey("planner")
if err != nil || a == nil {
return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移
}
tmpl := agent.BuiltinPromptSeeds()["planner"]
if tmpl == "" {
return
}
// 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。
if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl {
return
}
if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil {
log.Printf("[prompts] planner 提示词重刷为新默认失败: %v", err)
return
}
log.Printf("[prompts] planner 提示词已追加新默认版本(精简重构+克制降级去重+深度优先+否定复核上界,一次性)")
}
// reseedWorkerPrompt 把 worker 提示词刷成【当前代码默认】——默认正文 record_fact 段删掉了「否定类结论
// 写观察+试探性读法」整句、并把 confidence(observed/inferred)与「是否穷尽本意图手段」解耦(这些易误导规划者),
// 同时把 facts 数组分条收紧为「彼此完全独立、无法归并」的极少数例外。bump flag 至 v3 让存量旧库再刷一次。
// SeedPromptIfEmpty 首插入only,旧库已有版本收不到,故用版本管理【追加一个新版本】并切过去,旧版本仍保留在历史里可找回。
// settings flag 守卫 → 只做一次。全新库无需处理。与 reseedGoalsPrompt 完全同构。
func (s *Server) reseedWorkerPrompt() {
const flag = "worker_prompt_compact_v4"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
a, err := s.m.pg.GetAgentByKey("worker")
if err != nil || a == nil {
return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移
}
tmpl := agent.BuiltinPromptSeeds()["worker"]
if tmpl == "" {
return
}
// 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。
if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl {
return
}
if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil {
log.Printf("[prompts] worker 提示词重刷为新默认失败: %v", err)
return
}
log.Printf("[prompts] worker 提示词已追加新默认版本(查上下文段收敛为 list_assets/list_findings,去掉 list_facts/node_detail/asset_neighbors,一次性)")
}
// reporterToolCallMessage 必须无条件要求先读一次 get_finding_traffic 再写报告。
// 该工具是只读的、「不依赖捕获开关」,自动绑定关不关都能读到人工绑定的证据。若这里
// 写成「启用自动绑定才读」,默认关闭配置下 reporter 就不会传 evidence_version,
// SetFindingReportVersionByNodeID 便按 legacy 语义写 -1,漏洞详情与 Markdown 导出
// 从此常驻「证据已变更,报告待更新」,而 UI 上没有任何入口能把它清掉。
const reporterToolCallMessage = "上面刚有一个漏洞被 report_finding 登记。请读取返回 JSON 的 finding_id(独立漏洞记录 ID)与 finding_node_id(探索节点 ID)," +
"先用 get_finding_traffic(finding_id) 读取当前证据清单及其 version(空清单是正常情况,照常写报告);" +
"若运行指引启用自动绑定,在读取前先核实并关联本次漏洞的流量。节点详情使用 finding_node_id。" +
"最后调用 update_finding_report(finding_id=finding_node_id, report, evidence_version=实际读取版本) 保存," +
"evidence_version 必须传,否则报告会被永久标记为待更新。不要混用两种编号。"
// 旧版触发消息(0.3.8 及更早)。只有仍与它逐字相同的记录才会被迁移覆盖,用户改过的保持原样。
const reporterToolCallMessageV1 = "上面刚有一个漏洞被 report_finding 登记。请从触发上下文里取出 finding_id" +
"(工具返回 \"finding recorded: <id>\" 里的数字)与任务 id,按你的职责撰写该漏洞的详细报告," +
"最后调用 update_finding_report(finding_id, report) 保存。"
// upgradeReporterTriggerMessage 把老库里仍是默认文案的 reporter 触发消息刷成新版本。
// seedReporterAgent 受 reporter_agent_seed_v1 守卫且只在新建 agent 时写触发器,所以
// 升级上来的库拿不到新文案 —— 工具 schema 由 seedFindingTrafficTools 补齐了
// evidence_version,但没有任何东西告诉 reporter 去用它。一次性,且只覆盖未被改动的文案。
func (s *Server) upgradeReporterTriggerMessage() {
const flag = "reporter_trigger_evidence_version_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 只尝试一次
triggers, err := s.m.pg.ListTriggersFor("reporter")
if err != nil {
log.Printf("[reporter] 读取触发器失败: %v", err)
return
}
for _, t := range triggers {
if !t.OnToolCall || t.ToolCallMessage != reporterToolCallMessageV1 {
continue // 用户改过或不是 finding 触发器,不动。
}
t.ToolCallMessage = reporterToolCallMessage
if err := s.m.pg.UpdateTrigger(t); err != nil {
log.Printf("[reporter] 升级触发消息失败: %v", err)
return
}
log.Printf("[reporter] 触发消息已升级为读取并回传 evidence_version")
}
}
// reporterAgentName·reporterAgentDescription 은 시드되는 reporter 에이전트의 표시 전용
// 라벨이다(한국어화). 에이전트의 두뇌(段[A] agent.ReporterDefaultPrompt)와 트리거 주입
// 메시지(reporterToolCallMessage)는 모델 입력이라 중국어 원문을 보존하지만, 이 이름·설명은
// `agentDTO` 로 system/agents UI 에만 렌더되고(server_mgmt.go) 어떤 프롬프트·에이전트 선택에도
// 들어가지 않는 순수 UI 라벨이라 한국어화한다(BRIEF 사용자 노출 산출물 한국어화 범위). 트리거는
// report_finding 도구 호출 기반(OnToolCall·결정적)이라 reporter 는 key "reporter" 로 참조될 뿐
// 표시 이름으로 선택되지 않는다. 회귀 방어: reporter_seed_localized_test.go. [[F35]]
const reporterAgentName = "报告编写"
const reporterAgentDescription = "编写漏洞详细报告:发现漏洞后会自动触发,查看证据和执行过程后编写 Markdown 报告并记录到该漏洞中。"
// seedReporterAgent 预置一个「보고서 작성」自定义 agent(builtin=false,可在 UI 编辑/删除):
// 绑定 update_finding_report + 任务查询工具,并挂一个「report_finding 被调用即触发」的
// 触发器 —— 每登记一个漏洞就唤起它写详细报告。一次性(settings flag 守卫):用户删掉后不再重建。
// 依赖:orchestration 工具已在本函数上方 SeedTool 入库,故绑定得上。
func (s *Server) seedReporterAgent() {
const flag = "reporter_agent_seed_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
if exist, _ := s.m.pg.GetAgentByKey("reporter"); exist != nil {
return // key 已被占用(用户手建过)——不覆盖
}
a, err := s.m.pg.CreateAgent("reporter", reporterAgentName, reporterAgentDescription)
if err != nil {
log.Printf("[reporter] 创建 agent 失败: %v", err)
return
}
if err := s.m.pg.SeedPromptIfEmpty(a.ID, agent.ReporterDefaultPrompt); err != nil {
log.Printf("[reporter] seed prompt 失败: %v", err)
}
// 触发运行策略:parallel + none —— 一漏洞一报告、多个 finding 并发各写各的。
// merge 必须为 none:否则(默认 all)一波 finding 会被合并成一次运行,并行就没意义。
// maxParallel=5:同时最多 5 个报告会话,避免瞬时太多 LLM 调用。
if err := s.m.pg.SetAgentTriggerBehavior("reporter", "parallel", "none", 5); err != nil {
log.Printf("[reporter] 设置触发运行策略失败: %v", err)
}
// 绑定它需要的工具:写报告 + 读证据/执行过程/态势。
if err := s.m.pg.AddAgentToToolBinding("reporter", []string{
"update_finding_report", "get_task_node_detail", "list_task_findings",
"get_task_worker_trace", "list_task_worker_traces", "search_task_worker_traces",
"get_task_graph",
}); err != nil {
log.Printf("[reporter] 绑定工具失败: %v", err)
}
// 触发器:report_finding 被调用即触发(工具返回 "finding recorded: <id>" 带上 finding_id,
// 任务 id 也在触发消息里)。
if _, err := s.m.pg.CreateTrigger(&db.AgentTrigger{
AgentKey: "reporter",
Enabled: true,
OnToolCall: true,
ToolNames: []string{"report_finding"},
ToolCallMessage: reporterToolCallMessage,
}); err != nil {
log.Printf("[reporter] 创建触发器失败: %v", err)
}
log.Printf("[reporter] 已预配置「%s」 agent + finding 触发器", reporterAgentName)
}
// seedAutoReportFindingBinding adds "auto" to report_finding's binding ONCE so
// conversation-context agents can call it without requiring an intent_id.
func (s *Server) seedAutoReportFindingBinding() {
const flag = "auto_report_finding_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
if err := s.m.pg.AddAgentToToolBinding("auto", []string{"report_finding"}); err != nil {
log.Printf("[auto] report_finding 默认绑定失败: %v", err)
return
}
_ = s.m.pg.SetSetting(flag, "true")
}
// seedPlannerDefaultBindings adds "planner" to report_finding's binding ONCE
// (guarded by a settings flag), so existing DBs — whose report_finding row was
// seeded as worker-only — also let the planner record findings. Fresh DBs already
// get it via PlannerTools(); this only backfills without overriding a user unbind.
func (s *Server) seedPlannerDefaultBindings() {
const flag = "planner_report_finding_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
if err := s.m.pg.AddAgentToToolBinding("planner", []string{"report_finding"}); err != nil {
log.Printf("[planner] report_finding 默认绑定失败: %v", err)
return
}
_ = s.m.pg.SetSetting(flag, "true")
}
// seedPlannerListAssetsBinding adds "planner" to list_assets's binding ONCE
// (guarded by a settings flag), so existing DBs — whose list_assets row was seeded
// as auto/pentest-only — also let the planner query the asset store by DSL. Fresh
// DBs already get it via PlannerTools(); this only backfills without overriding a
// user unbind.
func (s *Server) seedPlannerListAssetsBinding() {
const flag = "planner_list_assets_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
if err := s.m.pg.AddAgentToToolBinding("planner", []string{"list_assets"}); err != nil {
log.Printf("[planner] list_assets 默认绑定失败: %v", err)
return
}
_ = s.m.pg.SetSetting(flag, "true")
}
// seedCompanyScopeRebind changes add_company_scope's default binding ONCE on
// existing DBs (guarded by a settings flag): the tool moves off worker and onto
// planner — defining a company's asset scope is a planning/main/auto concern, not
// something a worker does mid-exploration. Fresh DBs already get planner via
// PlannerTools() and lack worker via WorkerTools(); this only backfills old rows.
// One-shot + flag-guarded so a user who later re-binds worker isn't overridden.
func (s *Server) seedCompanyScopeRebind() {
const flag = "company_scope_rebind_v1" // worker→planner 默认绑定切换
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
if err := s.m.pg.AddAgentToToolBinding("planner", []string{"add_company_scope"}); err != nil {
log.Printf("[planner] add_company_scope 默认绑定失败: %v", err)
return
}
if err := s.m.pg.RemoveAgentFromTool("worker", "add_company_scope"); err != nil {
log.Printf("[worker] add_company_scope 解绑失败: %v", err)
return
}
_ = s.m.pg.SetSetting(flag, "true")
}
// seedWorkerReadToolsUnbind strips the read-context tools off worker's default
// binding ONCE on existing DBs (guarded by a settings flag): a worker executes one
// intent and writes back — reading facts/companies and listing all workers' traces is
// a planning/main concern, not the executor's. Fresh DBs already lack these via
// WorkerTools(); this only backfills old rows without overriding a user who
// deliberately re-binds worker. Each RemoveAgentFromTool is per-tool +
// membership-guarded, so planner/mainagent bindings of the same tool are untouched.
//
// NOTE: search_all_worker_traces / get_worker_trace / node_detail are intentionally NOT
// unbound — worker owns them for cross-work look-back + node drill-down (see WorkerTools).
// They used to be in this list back when worker lacked them; seedWorkerReadbackRebind
// repairs DBs whose old run stripped them.
func (s *Server) seedWorkerReadToolsUnbind() {
const flag = "worker_readtools_unbind_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
for _, k := range []string{
"list_facts", "list_companies", "list_worker_traces",
} {
if err := s.m.pg.RemoveAgentFromTool("worker", k); err != nil {
log.Printf("[worker] %s 从 worker 解绑失败: %v", k, err)
return // 出错则不落 flag,下次启动重试
}
}
_ = s.m.pg.SetSetting(flag, "true")
}
// seedWorkerReadbackRebind re-binds the cross-work look-back / drill-down tools onto
// worker ONCE (guarded by a settings flag): an earlier seedWorkerReadToolsUnbind wrongly
// stripped search_all_worker_traces / get_worker_trace / node_detail from worker after
// they had been added to WorkerTools(), so any DB that ran that migration lost them.
// Fresh DBs already have them via WorkerTools() and this is a harmless no-op there.
// One-shot + flag-guarded so a user who later deliberately unbinds them isn't overridden.
func (s *Server) seedWorkerReadbackRebind() {
const flag = "worker_readback_rebind_v2" // v2: 追加 node_detail
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
if err := s.m.pg.AddAgentToToolBinding("worker", []string{
"search_all_worker_traces", "get_worker_trace", "node_detail",
}); err != nil {
log.Printf("[worker] 回看/详情工具补绑失败: %v", err)
return // 出错则不落 flag,下次启动重试
}
_ = s.m.pg.SetSetting(flag, "true")
}
// seedAutoDefaultBindings adds "auto" to the task-op + platform tools' bindings
// ONCE (guarded by a settings flag), so existing DBs whose tool rows were seeded
// before Auto existed still give Auto its default toolset — without re-adding it
// after a user deliberately unbinds.
func (s *Server) seedAutoDefaultBindings() {
const flag = "auto_default_bindings_v3" // v3: 替换旧资产工具名,加入 insert_assets/add_company_scope
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
keys := make([]string, 0, len(platformToolKeys)+12)
for _, t := range s.orchestrationTools() {
keys = append(keys, t.Name())
}
keys = append(keys, platformToolKeys...)
// 资产工具:Auto 操作平台常要看/登记资产、管理公司范围。
keys = append(keys, "insert_assets", "add_company_scope", "list_assets")
if err := s.m.pg.AddAgentToToolBinding("auto", keys); err != nil {
log.Printf("[auto] 默认绑定失败: %v", err)
return
}
_ = s.m.pg.SetSetting(flag, "true")
}