package agent
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
)
// =====================================================================
// Unified asset insertion tools
// =====================================================================
// SetAssetStore wires the asset store and company store onto this ToolSet
// so the insert_assets, add_company_scope, and list_assets tools are active.
func (t *ToolSet) SetAssetStore(as *db.AssetStore, cs *db.CompanyStore) {
t.as = as
t.cs = cs
}
// assetInputItem is one element of the insert_assets "assets" array.
type assetInputItem struct {
Type string `json:"type"` // root_domain|ip|subdomain|app|service|endpoint
// ---- root_domain / subdomain ----
Domain string `json:"domain"`
ICP string `json:"icp"`
RecordType string `json:"record_type"`
RecordValue []string `json:"record_value"`
// ---- ip ----
IP string `json:"ip"`
BoundDomains []string `json:"bound_domains"`
OpenPorts []db.PortService `json:"open_ports"`
// ---- app ----
AppName string `json:"app_name"`
BundleID string `json:"bundle_id"`
Category string `json:"category"`
Description string `json:"description"`
AppICP string `json:"app_icp"`
CompanyID *int64 `json:"company_id"` // explicit company link (app only; others auto-attribute via scope)
// ---- service (http) ----
URL string `json:"url"`
Technologies []string `json:"technologies"`
StatusCode *int `json:"status_code"`
ContentLength *int64 `json:"content_length"`
PageTitle string `json:"page_title"`
FaviconMMH3 string `json:"favicon_mmh3"`
Auth []map[string]any `json:"auth"`
ServiceName string `json:"service_name"`
ServiceIP string `json:"service_ip"` // optional enrichment IP
// ---- service (other) ----
Port int `json:"port"`
Proto string `json:"proto"`
// ---- endpoint ----
Method string `json:"method"`
Params []map[string]any `json:"params"`
// Related marks whether this asset is relevant to the CURRENT test task and
// should therefore auto-enter the coverage system (task_scope denominator).
// nil = default true (relevant → auto-scoped). false = insert into the shared
// asset库 but keep it OUT of this task's coverage. Only consulted when the task
// has the coverage feature enabled; ignored (no auto-scope either way) when off.
Related *bool `json:"related"`
}
// insertAssets is the unified insert_assets agent tool.
func (t *ToolSet) insertAssets() actool.CoreTool {
return writeTool(
"insert_assets",
"发现新资产时立即批量插入资产到资产库。支持一次提交多种资产类型:root_domain/ip/subdomain/app/service/endpoint。\n"+
"每条资产的字段说明:\n"+
"• root_domain:domain(根域名, 必填)、icp(备案, 可选)\n"+
"• ip:ip(IP地址, 必填, 必须是 IPv4/IPv6 地址而非主机名)、bound_domains([域名])、open_ports([{port,service}])\n"+
"• subdomain:domain(子域名, 必填)、record_type(A/AAAA/CNAME等)、record_value(记录值)、icp(可选)\n"+
"• app:app_name(应用名, 必填)、bundle_id(bundle id, 可选)、category、description、app_icp、company_id(归属企业 id, 可选)\n"+
"• service(http):url(必填)、technologies([指纹])、status_code、content_length、page_title、favicon_mmh3、auth([{type,username,password,...}])、service_ip\n"+
"• service(other):service_name(必填)、ip或domain(至少一个)、port(必填)、auth([...])\n"+
"• endpoint:url(必填)、method(必填)、params([{location,name,value,type}])、service_ip\n"+
"auth/technologies/params 都是【追加合并】(append),不会覆盖原有值。\n"+
"返回:{results:[{index,id,type}], errors:[{index,error}]}",
obj(map[string]any{
// task_id 不暴露给模型:worker 归属哪个 task 由程序经 SetTaskID 权威赋值(见 handler)。
"assets": map[string]any{
"type": "array",
"description": "资产数组,每个元素对应一条资产记录",
"items": obj(map[string]any{
"type": map[string]any{
"type": "string",
"enum": []string{"root_domain", "ip", "subdomain", "app", "service", "endpoint"},
"description": "资产类型",
},
// root_domain / subdomain
"domain": str("根域名或子域名(root_domain/subdomain 必填)"),
"icp": str("ICP 备案号(可选)"),
"record_type": str("DNS 解析类型:A/AAAA/CNAME/MX 等(subdomain 可选)"),
"record_value": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
"description": "DNS 解析值列表(subdomain 可选,如 [\"1.2.3.4\",\"2.3.4.5\"])",
},
// ip
"ip": str("IP 地址,必须是 IPv4/IPv6 地址,不能填主机名(主机名请用 type=subdomain 的 domain 字段);ip 类型必填;service/endpoint 类型可填,用于关联 IP"),
"bound_domains": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
"description": "该 IP 绑定的域名列表(ip 类型可选)",
},
"open_ports": map[string]any{
"type": "array",
"description": "开放端口列表(ip 类型可选)",
"items": obj(map[string]any{
"port": intp("端口号"),
"service": str("服务名称,如 http/ssh/mysql 等(可选)"),
}, "port"),
},
// app
"app_name": str("应用名称(app 类型必填)"),
"bundle_id": str("Bundle ID(app 类型可选)"),
"category": str("应用分类(可选)"),
"description": str("应用描述(可选)"),
"app_icp": str("应用 ICP 备案(可选)"),
"company_id": intp("归属企业 id(app 类型可选;app 无法靠 scope 自动归因,需显式指定。id 由 add_company_scope 返回)"),
// service (http)
"url": str("完整 URL,含协议和端口(HTTP 服务必填;service_type 自动设为 http)"),
"status_code": intp("HTTP 响应状态码,如 200/301/403/404(可选)"),
"content_length": map[string]any{
"type": "integer",
"description": "HTTP 响应体字节数(可选)",
},
"page_title": str("页面
内容(可选)"),
"favicon_mmh3": str("favicon MMH3 哈希(可选)"),
"technologies": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
"description": "指纹/技术栈列表,如 [\"Nginx\",\"Vue\",\"Bootstrap\"](可选)",
},
"auth": map[string]any{
"type": "array",
"description": "发现的认证信息列表,每条含 type/username/password 等字段(可选,追加不覆盖)",
"items": map[string]any{"type": "object"},
},
// service (other,非 HTTP)
"service_name": str("服务名称,如 ssh/mysql/redis(service 非 HTTP 时必填)"),
"port": intp("端口号(service 非 HTTP 时必填)"),
// endpoint
"method": str("HTTP 方法:GET/POST/PUT/PATCH/DELETE 等(endpoint 必填)"),
"params": map[string]any{
"type": "array",
"description": "请求参数列表,每条含 location(query/body/header/path)/name/value/type(可选,追加不覆盖)",
"items": map[string]any{"type": "object"},
},
// 覆盖度相关:该资产是否与当前测试任务有关。
"related": map[string]any{
"type": "boolean",
"description": "该资产是否与【当前测试任务】相关:true(默认)才自动纳入资产覆盖度(测试范围分母),该资产会进入待测资产中,如果是和任务无关的,例如CDN仅存储静态资源类,必须设置为false或不进行资产插入;false 则只入库、不计入本任务覆盖度(如顺带发现的旁站/无关资产)。仅在任务开启资产覆盖度功能时生效。",
},
}, "type"),
},
}, "assets"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
if t.as == nil {
return actool.Errorf("insert_assets 未启用: AssetStore 未初始化"), nil
}
var a struct {
Assets []assetInputItem `json:"assets"`
}
if err := json.Unmarshal(in, &a); err != nil {
return actool.Errorf("invalid input: " + err.Error()), nil
}
// task_id 由程序权威赋值(worker: SetTaskID),不接受模型传入——避免模型漏传/错传
// 导致资产未归任务或归错任务。无任务上下文的调用方(auto/pentest/chat)其 t.taskID=0。
taskID := t.taskID
type result struct {
Index int `json:"index"`
ID int64 `json:"id"`
Type string `json:"type"`
}
type errEntry struct {
Index int `json:"index"`
Error string `json:"error"`
}
var results []result
var errs []errEntry
for i, item := range a.Assets {
typ := strings.TrimSpace(item.Type)
var id int64
var err error
switch typ {
case "root_domain":
id, err = t.as.UpsertRootDomain(db.UpsertRootDomainReq{
Domain: item.Domain,
ICP: item.ICP,
TaskID: taskID,
})
case "ip":
id, err = t.as.UpsertIP(db.UpsertIPReq{
IP: item.IP,
BoundDomains: item.BoundDomains,
OpenPorts: item.OpenPorts,
TaskID: taskID,
})
case "subdomain":
id, err = t.as.UpsertSubdomain(db.UpsertSubdomainReq{
Domain: item.Domain,
RecordType: item.RecordType,
RecordValue: item.RecordValue,
ICP: item.ICP,
TaskID: taskID,
})
case "app":
id, err = t.as.UpsertApp(db.UpsertAppReq{
Name: item.AppName,
BundleID: item.BundleID,
Category: item.Category,
Description: item.Description,
ICP: item.AppICP,
CompanyID: item.CompanyID,
TaskID: taskID,
})
case "service":
// distinguish HTTP vs other by presence of url
if item.URL != "" {
// agent may send "ip" or "service_ip" for the enrichment IP; accept both
svcIP := item.ServiceIP
if svcIP == "" {
svcIP = item.IP
}
id, err = t.as.UpsertHTTPService(db.UpsertHTTPServiceReq{
URL: item.URL,
Technologies: item.Technologies,
StatusCode: item.StatusCode,
ContentLength: item.ContentLength,
PageTitle: item.PageTitle,
FaviconMMH3: item.FaviconMMH3,
Auth: item.Auth,
IP: svcIP,
TaskID: taskID,
})
} else {
id, err = t.as.UpsertOtherService(db.UpsertOtherServiceReq{
Domain: item.Domain,
IP: item.IP,
Port: item.Port,
ServiceName: item.ServiceName,
Auth: item.Auth,
TaskID: taskID,
})
}
case "endpoint":
id, err = t.as.UpsertEndpoint(db.UpsertEndpointReq{
URL: item.URL,
Method: item.Method,
Params: item.Params,
IP: item.ServiceIP,
TaskID: taskID,
})
default:
errs = append(errs, errEntry{Index: i, Error: "unknown type: " + typ})
continue
}
if err != nil {
errs = append(errs, errEntry{Index: i, Error: err.Error()})
continue
}
results = append(results, result{Index: i, ID: id, Type: typ})
t.writes.Assets++
t.anchorOwner(id)
if taskID > 0 {
var sourceNodeID *int64
if t.ownerNode > 0 {
nodeID := t.ownerNode
sourceNodeID = &nodeID
}
summary := "Agent 通过 insert_assets 登记"
if t.ownerNode > 0 {
summary = fmt.Sprintf("Worker 意图 #%d 通过 insert_assets 登记", t.ownerNode)
}
_ = t.as.SetTaskAssetSource(taskID, id, "agent", summary, sourceNodeID)
}
// 自动入测试范围(source='auto'):只对 worker 顶层显式插入的这一项,按其
// 类型加保守范围;side-effect 派生的资产不经此处,故范围不盲目扩大。taskID=0 时无操作。
// 资产覆盖度功能关闭时不再累积测试范围(分母);related=false(与当前任务无关)
// 的资产也只入库、不计入覆盖度。related 省略/null 视为 true(默认纳入)。
related := item.Related == nil || *item.Related
if !t.coverageDisabled && related {
svcIP := item.ServiceIP
if svcIP == "" {
svcIP = item.IP
}
_ = t.as.AddAutoScope(taskID, typ, item.Domain, item.URL, svcIP)
}
}
return jsonResult(map[string]any{
"results": results,
"errors": errs,
})
},
)
}
// addCompanyScope writes to company_scope table and triggers asset attribution.
func (t *ToolSet) addCompanyScope() actool.CoreTool {
return writeTool(
"add_company_scope",
"把域名/IP/CIDR/ICP备案/企业关键词加入某公司的【资产范围】——域名、网络和ICP会自动认领命中的资产,关键词只提供给Agent作为范围提示。\n"+
"公司名唯一:company 不存在则新建,已存在则复用(只把范围并进去)。\n"+
"scope 一行一条,系统自动识别:根域名 / URL / 单个 IP / CIDR 网段 / ICP备案 / 企业关键词。\n"+
"务必给 reason 说明归属依据(whois/证书/ASN 等)。\n"+
"护栏:拒绝裸 TLD 与过宽网段(IPv4前缀需为/16-/32、IPv6前缀需为/32-/128),非法行会被跳过并在 errors 返回。",
obj(map[string]any{
"company": str("公司名(不存在则新建、存在则复用;名称唯一)"),
"scope": str("资产范围,一行一条:域名 / URL / IP / CIDR / ICP备案 / 企业关键词"),
"reason": str("归属依据(证据/来源),务必填写"),
"logo": str("公司图标 URL(可选;仅新建公司时生效)"),
}, "company", "scope"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
if t.cs == nil {
return actool.Errorf("add_company_scope 未启用: CompanyStore 未初始化"), nil
}
var a struct {
Company string `json:"company"`
Scope string `json:"scope"`
Reason string `json:"reason"`
Logo string `json:"logo"`
}
if err := json.Unmarshal(in, &a); err != nil {
return actool.Errorf(err.Error()), nil
}
if strings.TrimSpace(a.Company) == "" {
return actool.Errorf("company 不能为空"), nil
}
companyID, _, err := t.cs.UpsertCompany(a.Company, a.Logo)
if err != nil {
return actool.Errorf("创建/获取公司失败: " + err.Error()), nil
}
lines := splitLines(a.Scope)
added, skipped, invalid, errMsgs := t.cs.AddScope(companyID, lines, a.Reason)
out := map[string]any{
"company_id": companyID,
"added": added,
"skipped": skipped,
"invalid": invalid,
}
if len(errMsgs) > 0 {
out["errors"] = errMsgs
}
return jsonResult(out)
},
)
}
// addTaskScope lets the plan agent add test scope to THE CURRENT TASK — the coverage
// denominator and the task's authorization edge. Worker discoveries are auto-scoped
// (precise host) by insertAssets; this tool is for DELIBERATELY WIDENING: pull a whole
// root domain or whole company into scope, or add a specific subdomain / ip.
func (t *ToolSet) addTaskScope() actool.CoreTool {
return writeTool(
"add_task_scope",
"把测试范围加入【本任务】——这是资产测试覆盖度的分母,也是本任务的授权边界。\n"+
"kind 支持:company(整个公司名下资产) / root_domain(整个根域,含所有子域) / subdomain(单个精确子域) / ip / cidr / icp / keyword。\n"+
"说明:worker 逐个碰到的主机会被系统【自动】加进范围(精确子域);本工具用于【主动扩大】——把整个根域/整个公司纳入,或补充指定某子域/IP。\n"+
"value:company 传公司名或 id(公司须已存在);root_domain/subdomain 传域名;ip/cidr 传 IP 或网段;icp/keyword 传备案号或企业关键词。\n"+
"务必给 reason 说明依据(可审计)。多条用 entries 数组。",
obj(map[string]any{
"entries": map[string]any{"type": "array", "description": "批量:[{kind, value}]。kind∈company/root_domain/subdomain/ip/cidr/icp/keyword。", "items": map[string]any{"type": "object"}},
"kind": str("[单条] company / root_domain / subdomain / ip / cidr / icp / keyword"),
"value": str("[单条] 公司名或id / 域名 / IP / CIDR / ICP / 关键词"),
"reason": str("加入依据(用于审计),务必填写"),
}),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
if t.as == nil {
return actool.Errorf("add_task_scope 未启用: AssetStore 未初始化"), nil
}
if t.taskID <= 0 {
return actool.Errorf("add_task_scope 需要任务上下文(当前无 task)"), nil
}
type scopeEntry struct {
Kind string `json:"kind"`
Value string `json:"value"`
}
var a struct {
Entries []scopeEntry `json:"entries"`
scopeEntry // 单条模式
Reason string `json:"reason"`
}
_ = json.Unmarshal(in, &a)
items := a.Entries
if len(items) == 0 {
items = []scopeEntry{a.scopeEntry}
}
var added []map[string]any
errs := map[string]string{}
for i, e := range items {
ts, err := t.as.AddAgentScope(t.taskID, strings.TrimSpace(e.Kind), e.Value, a.Reason, "agent")
if err != nil {
errs[strconv.Itoa(i)] = err.Error()
continue
}
added = append(added, map[string]any{"kind": ts.Kind, "domain": ts.Domain, "net": ts.Net, "value": ts.Value, "company_id": ts.CompanyID})
}
out := map[string]any{"added": added}
if len(errs) > 0 {
out["errors"] = errs
}
return jsonResult(out)
},
)
}
// listUntestedAssets lets the plan agent pull the current + directly inherited
// scope's not-yet-tested assets on demand (filter by type, paginated).
func (t *ToolSet) listUntestedAssets() actool.CoreTool {
return readTool(
"list_untested_assets",
"查询【本任务及直接关联任务】范围内、还没被事实锚点覆盖的资产(关联范围只读,供你自己判断要不要补测,不代替你决策)。\n"+
"可选按资产类型过滤:root_domain/subdomain/service/app/endpoint/ip。\n"+
"分页:page 从 1 起、page_size 默认 10。返回 {assets:[{id,type,label}], total, page, page_size}。仅任务上下文可用。",
obj(map[string]any{
"type": str("资产类型过滤(可选):root_domain/subdomain/service/app/endpoint/ip"),
"page": intp("页码,从 1 起(默认 1)"),
"page_size": intp("每页数量(默认 10)"),
}),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
if t.as == nil {
return actool.Errorf("list_untested_assets 未启用: AssetStore 未初始化"), nil
}
if t.taskID <= 0 || t.ts == nil {
return actool.Errorf("list_untested_assets 需要任务上下文"), nil
}
var a struct {
Type string `json:"type"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
_ = json.Unmarshal(in, &a)
if a.Page <= 0 {
a.Page = 1
}
if a.PageSize <= 0 {
a.PageSize = 10
}
offset := (a.Page - 1) * a.PageSize
assets, total, err := t.as.ListUntestedAssetsWithSources(t.taskID, strings.TrimSpace(a.Type), a.PageSize, offset)
if err != nil {
return actool.Errorf(err.Error()), nil
}
return jsonResult(map[string]any{
"assets": assets, "total": total, "page": a.Page, "page_size": a.PageSize,
})
},
)
}
// listAssets lets an agent query the asset table.
func (t *ToolSet) listAssets() actool.CoreTool {
return readTool(
"list_assets",
"查询资产库。支持 DSL 表达式搜索、按 id/ids 直接取,分页。\n"+
"DSL 语法:field=value 模糊(ILIKE) | field==value 精确 | field!=value 排除 | port>80 数字比较 | 裸词=全文模糊。\n"+
"逻辑运算:AND / OR(关键字,AND 优先级高于 OR),括号分组。\n"+
"资产类型用独立的 type 参数过滤,DSL 里不含 type 字段。\n"+
"约束:未传 id/ids 时 dsl 必须非空——不允许无条件查询全部资产,必须带查询条件。\n"+
"字段一览:\n"+
" domain 域名(根域名/子域名/服务域名)\n"+
" root_domain 根域名(仅子域名/服务资产有)\n"+
" ip IPv4/IPv6 地址\n"+
" url 完整 URL(服务/接口)\n"+
" page_title 页面标题(HTTP 服务)\n"+
" icp ICP 备案号(根域名)\n"+
" service_name 服务名称(非 HTTP 服务)\n"+
" app_name 应用名称(app 类型)\n"+
" method HTTP 方法(接口类型,如 GET/POST)\n"+
" service_type HTTP 服务类型:http|other\n"+
" record_type DNS 解析类型(子域名,如 A/CNAME)\n"+
" technology 指纹/技术栈(数组字段,= 模糊 == 精确)\n"+
" port 端口号(整数,支持 > >= < <=)\n"+
" status_code HTTP 状态码(整数,支持 > >= < <=)\n"+
" company_id 归属企业 id(整数)\n"+
" task_id 来源任务 id(整数)\n"+
"示例:status_code>=400 AND technology=shiro\n"+
" method==POST AND url=/api/admin\n"+
" icp=京 OR icp=沪\n"+
" (port==80 OR port==443) AND technology=nginx",
obj(map[string]any{
"dsl": str(`DSL 查询表达式,参见工具描述。未传 id/ids 时必须非空。例:status_code>=400 technology=shiro / method==POST url=/api/admin`),
"type": str("资产类型过滤:root_domain|ip|subdomain|app|service|endpoint(独立字段,可与 dsl 叠加;单独 type 不足以查询,仍需 dsl)"),
"id": intp("直接按单个资产 id 取(可选,与 dsl/type 互斥)"),
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "直接按多个资产 id 取(可选,与 dsl/type 互斥)"},
"limit": intp("返回上限,默认 10(可选)"),
"offset": intp("分页偏移,默认 0(可选)"),
}),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
if t.as == nil {
return actool.Errorf("list_assets 未启用: AssetStore 未初始化"), nil
}
var a struct {
DSL string `json:"dsl"`
Type string `json:"type"`
ID int64 `json:"id"`
IDs []int64 `json:"ids"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
_ = json.Unmarshal(in, &a)
if a.Limit <= 0 {
a.Limit = 10
}
var assets []*db.Asset
var err error
switch {
case a.ID > 0:
assets, err = t.as.GetByIDs([]int64{a.ID})
case len(a.IDs) > 0:
assets, err = t.as.GetByIDs(a.IDs)
case a.DSL != "":
assets, err = t.as.QueryDSL(a.DSL, a.Type, a.Limit, a.Offset)
default:
return actool.Errorf("未传 id/ids 时 dsl 不能为空:不允许无条件查询全部资产,请提供查询条件"), nil
}
if err != nil {
return actool.Errorf("DSL 错误: " + err.Error()), nil
}
return jsonResult(map[string]any{
"count": len(assets),
"assets": assets,
})
},
)
}
// listCompanies lets an agent enumerate companies (企业) with their scope + asset count.
func (t *ToolSet) listCompanies() actool.CoreTool {
return readTool(
"list_companies",
"列出资产库中的【企业/公司】及其资产范围(scope)与已归属资产数。用于查看有哪些公司、"+
"拿到 company_id(insert_assets 关联 app、list_assets 按 company_id 过滤时用)。"+
"可选 search 按公司名模糊过滤(不区分大小写),留空返回全部。",
obj(map[string]any{
"search": str("按公司名模糊过滤(可选,不区分大小写);留空返回全部"),
}),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
if t.cs == nil {
return actool.Errorf("list_companies 未启用: CompanyStore 未初始化"), nil
}
var a struct {
Search string `json:"search"`
}
_ = json.Unmarshal(in, &a)
cos, err := t.cs.ListCompanies()
if err != nil {
return actool.Errorf("查询公司失败: " + err.Error()), nil
}
q := strings.ToLower(strings.TrimSpace(a.Search))
type companyOut struct {
ID int64 `json:"id"`
Name string `json:"name"`
AssetCount int `json:"asset_count"`
Scope []string `json:"scope"`
}
out := make([]companyOut, 0, len(cos))
for _, c := range cos {
if q != "" && !strings.Contains(strings.ToLower(c.Name), q) {
continue
}
scope := make([]string, 0, len(c.Scope))
for _, r := range c.Scope {
scope = append(scope, r.Raw)
}
out = append(out, companyOut{ID: c.ID, Name: c.Name, AssetCount: c.AssetCount, Scope: scope})
}
return jsonResult(map[string]any{"count": len(out), "companies": out})
},
)
}
// splitLines splits a multi-line string into non-empty trimmed lines.
func splitLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line != "" {
out = append(out, line)
}
}
return out
}
// WorkerTools returns the tool set for a work agent.
func (t *ToolSet) WorkerTools() []actool.CoreTool {
return []actool.CoreTool{
t.listFindings(), t.listFacts(), t.nodeDetail(),
t.addFinding(), t.recordFact(),
// cross-work retrieval
t.searchAllWorkerTraces(), t.listWorkerTraces(), t.getWorkerTrace(),
// asset management (handlers guard nil store internally)。
// add_company_scope 不给 worker:定义企业资产范围属规划/主控/Auto 的职责,worker 只执行探索。
t.insertAssets(), t.listAssets(), t.listCompanies(),
}
}
// MainAgentTools returns the human-interface tool set.
func (t *ToolSet) MainAgentTools() []actool.CoreTool {
return []actool.CoreTool{
t.graphOverview(), t.listFindings(), t.listFacts(), t.nodeDetail(),
t.expandDigest(), t.expandIndex(), // cold-digest §6.1
t.getWorkerOutput(), t.getWorkerTrace(), t.searchAllWorkerTraces(), t.addHint(), t.addIntent(),
// steer_work:人可对某条正在运行的意图(work)实时注入纠偏指令(不打断、不丢进展)。
t.steerWorkTool(),
// set_goals:人可在运行时给本任务补一个新的最终目标(规划者据此重判是否达成)。
t.setGoals(),
// set_constraints:人可在运行时给本任务补/改操作约束(allow/deny),约束 planner/worker 的探索边界。
t.setConstraints(),
// asset management (handlers guard nil store internally)
t.insertAssets(), t.addCompanyScope(), t.listAssets(),
t.addFinding(), t.recordFact(),
t.addTaskScope(),
// list_untested_assets:按需查本任务范围内未测资产(类型+分页),自行决定补测。
t.listUntestedAssets(),
}
}
// AllDomainTools returns the union of all domain tools across all agent types,
// deduped by name (mainagent order wins). Used by the server to build a registry
// for injecting domain tools into agents (Auto, custom) that don't own a per-task
// ToolSet. The caller provides real stores; tools are callable at taskID=0 scope.
func (t *ToolSet) AllDomainTools() []actool.CoreTool {
seen := map[string]bool{}
var out []actool.CoreTool
all := append(append(t.MainAgentTools(), t.PlannerTools()...), t.WorkerTools()...)
for _, tool := range all {
if !seen[tool.Name()] {
seen[tool.Name()] = true
out = append(out, tool)
}
}
return out
}