Files
dela 0335d572de
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
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

2055 lines
91 KiB
Go
Raw Permalink 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 agent
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/Autumn-27/artex/db"
acperm "github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool"
)
// compactIntents distills intents to {id, summary, state, asset_ids, parents,
// yields} so the planner sees both the direction and its LINEAGE — parents (the
// upstream nodes it derived from: facts/intents/findings) and yields (the facts/
// findings it produced) — without pulling full payloads. parentsOf/yieldsOf are
// built from the exploration edges in graph_overview.
func compactIntents(ns []*db.Node, parentsOf, yieldsOf map[int64][]int64) []map[string]any {
out := make([]map[string]any, 0, len(ns))
for _, n := range ns {
var p map[string]any
_ = json.Unmarshal(n.Payload, &p)
m := map[string]any{"id": n.ID, "summary": p["summary"], "state": n.State}
if n.Inherited {
m["source_task_id"] = n.SourceTaskID
m["inherited"] = true
}
// asset_ids is the structured "which assets this direction covers" signal for
// dedup; fall back to legacy payload keys (target_ids plural, then target_id
// single) so intents stored before the rename still surface their anchors.
if tg, ok := p["asset_ids"]; ok && tg != nil {
m["asset_ids"] = tg
} else if tg, ok := p["target_ids"]; ok && tg != nil {
m["asset_ids"] = tg
} else if tg, ok := p["target_id"]; ok && tg != nil && tg != "" {
m["asset_ids"] = []any{tg}
}
if ps := parentsOf[n.ID]; len(ps) > 0 {
m["parents"] = ps // 上游:本意图派生自哪些节点(多个事实可共同产生一个意图)
}
if ys := yieldsOf[n.ID]; len(ys) > 0 {
m["yields"] = ys // 下游:本意图产生了哪些事实/发现
}
out = append(out, m)
}
return out
}
// ToolSet exposes the PG-backed dual graph (asset + exploration) to an LLM agent.
// One ToolSet is created per planner/worker run; per-run signals live here.
type ToolSet struct {
findingRecorder FindingRecorder
as *db.AssetStore // asset store (optional; nil = asset tools not available)
cs *db.CompanyStore // company store (optional)
ts *db.ExplorationStore
worker string
taskID int64 // PG tasks.id; 0 when unknown (tests / orchestrator cross-task reads)
// coverageDisabled mirrors tasks.coverage_enabled=false. Stored inverted so the
// zero value (all existing ToolSet constructions) means ENABLED — matching the
// DB default (true). When true: graphOverviewData drops the coverage block, the
// auto-scope hook (insertAssets) is skipped, and add_task_scope/list_untested_assets
// are filtered out of the agent's tool list. The scope field stays regardless.
coverageDisabled bool
// ownerNode is the exploration node that writes attach to: assets this run
// touches get anchored to it as lineage/provenance (NOT visibility — the asset
// graph is global and shared). Worker = its claimed intent; planner = begin root.
ownerNode int64
GoalMet bool
Reason string
writes WriteCounts
// killWork, if set, terminates a running work by intent id (engine callback,
// wired by the planner). nil = the kill_work tool reports unavailable.
killWork func(intentID int64) error
// steerWork, if set, queues a mid-run course-correction for the work running an
// intent id (engine callback, wired by the planner): the worker injects it before
// its next tool call and re-plans, without being killed. nil = tool unavailable.
steerWork func(intentID int64, msg string) error
// enrich, if set, receives async auto-completion triggers (DNS resolve for a
// domain, HTTP probe for a site). nil = no engine enrichment.
enrich EnrichTrigger
// notify, if set, wakes the task's planner after a graph change that should be
// re-planned promptly (currently: a new hint). nil = no wake (the hint is still
// stored and read on the next round triggered by other events). debounced.
notify func()
// notifyFinding, if set, wakes the task's planner when this run reports a finding,
// carrying (intentID, summary) so the round can spell out which intent found what.
// Wired for workers; nil elsewhere → falls back to notify (bare wake).
notifyFinding func(intentID int64, summary string)
// resumeTask, if set, revives the task after a graph change that should make a
// stopped task run again (currently: set_goals adds a goal). It flips a terminal/
// paused task back to running and (re)starts the engine loops — a plain notify()
// can't, because the planner's terminal gate swallows wakes. Wired ONLY for the
// main agent (human steering); nil for the goals decomposer and workers.
resumeTask func()
// notifyGoal, if set, wakes the planner AND records ONE "人新增了 N 个目标:…" trigger
// for a whole set_goals call (batch-aware — one call, one trigger, not one per goal)
// so the next round spells out the added goals (instead of the planner having to
// spot new open goals in the overview). Wired ONLY for the main agent; nil for the
// goals decomposer (round-0 has no running planner to inform) and workers → those
// fall back to the bare notify.
notifyGoal func(texts []string)
// notifyHint, if set, wakes the planner AND records ONE "人新增了 N 条战略提示:…"
// trigger for a whole add_hint call (batch-aware — one call, one trigger) so the next
// round is told the round was fired by a new hint and spells the hint out, instead of
// the planner having to spot it folded into the graph overview. Wired for the main
// agent + cross-task orchestration; nil elsewhere → falls back to the bare notify.
notifyHint func(texts []string)
}
// SetNotifyGoal wires the goal-add trigger callback (see ToolSet.notifyGoal). Set only
// by the main-agent chat, so runtime-added goals are announced to the planner by name.
func (t *ToolSet) SetNotifyGoal(fn func([]string)) { t.notifyGoal = fn }
// SetNotifyHint wires the hint-add trigger callback (see ToolSet.notifyHint). Set by
// the main-agent chat and cross-task orchestration, so a runtime-added hint fires a
// planner round announced by name instead of a bare wake.
func (t *ToolSet) SetNotifyHint(fn func([]string)) { t.notifyHint = fn }
// SetResumeTask wires the task-revive callback (see ToolSet.resumeTask). Set only by
// the main-agent chat, so runtime-added goals can pull a finished task back to running.
func (t *ToolSet) SetResumeTask(fn func()) { t.resumeTask = fn }
// SetNotify wires the planner-wake callback (see ToolSet.notify). Set by callers
// that hold the task handle (main-agent chat, cross-task orchestration).
func (t *ToolSet) SetNotify(fn func()) { t.notify = fn }
// SetNotifyFinding wires the finding-wake callback (see ToolSet.notifyFinding).
func (t *ToolSet) SetNotifyFinding(fn func(int64, string)) { t.notifyFinding = fn }
// EnrichTrigger is the enrichment engine seen from the tool layer (see package
// enrich). Kept as an interface here to avoid coupling agent → enrich.
type EnrichTrigger interface {
ResolveDomain(id int64, host string)
ProbeSite(id int64, url string)
}
// WriteCounts breaks down what a worker persisted this run, by node kind, so the
// engine can log an accurate "wrote back" summary instead of lumping assets and
// findings under "facts" (record_fact → Facts, insert_assets → Assets,
// report_finding → Findings; each element of a batch counts once).
type WriteCounts struct {
Facts int
Assets int
Findings int
}
// Total is every node persisted this run, regardless of kind — the
// "explored but persisted nothing" signal (Total == 0).
func (w WriteCounts) Total() int { return w.Facts + w.Assets + w.Findings }
// String renders the per-kind breakdown for logs, e.g. "事实1 资产25 漏洞0".
func (w WriteCounts) String() string {
return fmt.Sprintf("事实%d 资产%d 漏洞%d", w.Facts, w.Assets, w.Findings)
}
// Writes reports what this run wrote back, split by node kind (so the engine can
// tell "explored but persisted nothing" apart from a completed intent, and log an
// honest breakdown instead of calling assets/findings "facts").
func (t *ToolSet) Writes() WriteCounts { return t.writes }
func NewToolSet(ts *db.ExplorationStore, worker string) *ToolSet {
return &ToolSet{ts: ts, worker: worker}
}
// SetTaskID sets the PG task id on this ToolSet so that report_finding can
// dual-write to the standalone findings table (which survives task deletion).
func (t *ToolSet) SetTaskID(id int64) { t.taskID = id }
// SetCoverageEnabled records whether this task has the asset-coverage feature on
// (default enabled). Passing false makes graphOverviewData omit the coverage block
// and DropCoverageTools filter the two coverage-only tools out of the agent's tool
// list. It does NOT stop scope accumulation: insertAssets' auto-scope hook runs
// either way, because task_scope is the task's range boundary (the filter basis for
// asset queries), not merely a coverage denominator.
func (t *ToolSet) SetCoverageEnabled(enabled bool) { t.coverageDisabled = !enabled }
// CoverageDisabled reports whether the coverage feature is off for this task.
func (t *ToolSet) CoverageDisabled() bool { return t.coverageDisabled }
// coverageOnlyTools are the LLM tools that only make sense when asset coverage is
// on. When the feature is off they are filtered out of the agent's tool list so
// they neither pollute the prompt nor let the model build a disabled denominator.
// add_task_scope is deliberately NOT here: task_scope is the task's range boundary
// (the filter basis for asset queries), not merely a coverage denominator, so the
// agents that own范围定义 keep it either way — in lockstep with insertAssets'
// auto-scope hook, which also runs regardless of the switch.
var coverageOnlyTools = map[string]bool{"list_untested_assets": true}
// DropCoverageTools returns tools with the coverage-only ones removed when this
// task has the feature disabled; otherwise it returns tools unchanged.
func (t *ToolSet) DropCoverageTools(tools []actool.CoreTool) []actool.CoreTool {
if !t.coverageDisabled {
return tools
}
out := tools[:0:0]
for _, tool := range tools {
if coverageOnlyTools[tool.Name()] {
continue
}
out = append(out, tool)
}
return out
}
// Cross-task reuse: exported accessors returning the per-task tool logic bound to
// THIS ToolSet's store. Host-side orchestration tools build a ToolSet for an
// arbitrary task, then Call these — so cross-task reads/hint reuse the exact
// same logic as the in-task tools. (readTool ignores ToolContext, so Call(…,nil)
// is safe; add_hint is a writeTool but also doesn't deref the context here.)
func (t *ToolSet) GraphOverviewTool() actool.CoreTool { return t.graphOverview() }
func (t *ToolSet) ListFindingsTool() actool.CoreTool { return t.listFindings() }
func (t *ToolSet) GetWorkerTraceTool() actool.CoreTool { return t.getWorkerTrace() }
func (t *ToolSet) ListWorkerTracesTool() actool.CoreTool { return t.listWorkerTraces() }
func (t *ToolSet) SearchWorkerTracesTool() actool.CoreTool { return t.searchAllWorkerTraces() }
func (t *ToolSet) NodeDetailTool() actool.CoreTool { return t.nodeDetail() }
func (t *ToolSet) AddHintTool() actool.CoreTool { return t.addHint() }
// SetEnrich wires the async enrichment engine (DNS/HTTP auto-completion).
func (t *ToolSet) SetEnrich(e EnrichTrigger) { t.enrich = e }
// SetOwnerNode sets the exploration node that writes anchor to (worker: its
// intent node; planner/main: the begin root). Assets created/referenced while
// ownerNode is set are anchored to it as lineage (not visibility).
func (t *ToolSet) SetOwnerNode(id int64) { t.ownerNode = id }
// anchorOwner records a lineage edge from this run's owner node to an asset
// (no-op if unset). Provenance only — the asset graph is global and shared, so
// this no longer affects which assets a task can read.
func (t *ToolSet) anchorOwner(assetID int64) {
if t.ts != nil && t.ownerNode > 0 && assetID > 0 {
_ = t.ts.Anchor(t.ownerNode, assetID)
}
}
// pid parses an id that may arrive as a JSON number or string ("" / 0 → 0).
func pid(raw json.RawMessage) int64 {
if len(raw) == 0 {
return 0
}
var n int64
if json.Unmarshal(raw, &n) == nil {
return n
}
var s string
if json.Unmarshal(raw, &s) == nil {
v, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
return v
}
return 0
}
// pidList parses a list of ids (number|string), dropping zeros/invalids.
func pidList(raw []json.RawMessage) []int64 {
var out []int64
for _, r := range raw {
if v := pid(r); v > 0 {
out = append(out, v)
}
}
return out
}
func obj(props map[string]any, required ...string) map[string]any {
m := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
m["required"] = required
}
return m
}
func str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
func intp(desc string) map[string]any {
return map[string]any{"type": "integer", "description": desc}
}
func idp(desc string) map[string]any {
return map[string]any{"type": "integer", "description": desc}
}
func readTool(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, acperm.Context) acperm.Decision { return acperm.Allowed() },
Run: func(ctx context.Context, in json.RawMessage, _ *actool.ToolContext) (actool.Result, error) {
return run(ctx, in)
},
})
}
func writeTool(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, acperm.Context) acperm.Decision { return acperm.Allowed() },
Run: func(ctx context.Context, in json.RawMessage, _ *actool.ToolContext) (actool.Result, error) {
return run(ctx, in)
},
})
}
// readExpTool / writeExpTool build a domain tool whose handler dereferences the
// task-bound ExplorationStore. Two ToolSets carry a nil store: the catalog's
// seed-only shell (never called) and the server-level one behind buildDomainReg,
// which the tools table can bind to ANY agent — including ones that never run
// inside a task (auto/pentest/reporter/自定义 agent/旁路提问). Refusing there
// keeps a mis-bound tool a bad tool call; without the guard it was a nil deref,
// and tool handlers run on the harness's own goroutine, so the panic is out of
// reach of every recover() in the server and kills the whole process.
func (t *ToolSet) readExpTool(name, desc string, schema map[string]any, run func(context.Context, json.RawMessage) (actool.Result, error)) actool.CoreTool {
return readTool(name, desc, schema, t.needExploration(name, run))
}
func (t *ToolSet) writeExpTool(name, desc string, schema map[string]any, run func(context.Context, json.RawMessage) (actool.Result, error)) actool.CoreTool {
return writeTool(name, desc, schema, t.needExploration(name, run))
}
// needExploration wraps a handler so it only runs with an exploration store.
// Tools that degrade more usefully than "unavailable" (report_finding points at
// add_task_hint, set_goals/set_constraints at the task itself) keep their own
// bespoke guard instead.
func (t *ToolSet) needExploration(name string, run func(context.Context, json.RawMessage) (actool.Result, error)) func(context.Context, json.RawMessage) (actool.Result, error) {
return func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
if t.ts == nil {
return actool.Errorf(name + " 需要任务上下文(探索图):当前 agent 不在某个任务内运行,取不到任务的探索图,该工具不可用。请在任务内使用它,或改用带 task_id 的跨任务读取工具(get_task_node_detail / list_task_findings / get_task_graph 等)。"), nil
}
return run(ctx, in)
}
}
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
}
// --- read tools (planner + worker) ---
func (t *ToolSet) graphOverview() actool.CoreTool {
return t.readExpTool("graph_overview",
"(探索链路图)探索态势蒸馏摘要:资产计数、无接口的站点、frontier、发现、hints(人类/主 agent 的战略提示,生成意图时须纳入)。规划时先调它。",
obj(map[string]any{}),
func(context.Context, json.RawMessage) (actool.Result, error) {
return jsonResult(t.graphOverviewData())
})
}
// graphOverviewData computes the distilled situational snapshot shared by the
// graph_overview tool and the planner's wake-up prompt (which pre-injects it so
// the model needn't spend a turn calling the tool — every plan round starts with
// an empty context and always needs this first).
func (t *ToolSet) graphOverviewData() map[string]any {
out := map[string]any{}
// goals summary folded in so the planner needn't call list_goals each round.
goals, _ := t.ts.ListByKind(db.KindGoal, 100)
gsum := make([]map[string]any, 0, len(goals))
for _, g := range goals {
var p map[string]any
_ = json.Unmarshal(g.Payload, &p)
gsum = append(gsum, map[string]any{"id": g.ID, "state": g.State, "text": p["text"]})
}
out["goals"] = gsum
// hints: 人类/主 agent 通过 add_hint 挂上图的战略提示;folded in so the
// planner reads them every round when generating intents (否则只写不读).
hints, _ := t.ts.ListByKind(db.KindHint, 50)
hsum := make([]map[string]any, 0, len(hints))
for _, h := range hints {
var p map[string]any
_ = json.Unmarshal(h.Payload, &p)
hint := map[string]any{"id": h.ID, "state": h.State, "text": p["text"]}
if findingTrafficBindingEnabled() && p["traffic_refs"] != nil {
hint["traffic_refs"] = p["traffic_refs"]
}
hsum = append(hsum, hint)
}
out["hints"] = hsum
// lineage from the exploration edges: an intent's parents (what it
// derived_from — possibly several facts combined) and its yields (the
// facts/findings it produced). factFrom maps a fact → the intent that
// produced it. This is the relationship layer the flat lists lacked.
edges, _ := t.ts.Edges(5000)
parentsOf := map[int64][]int64{}
yieldsOf := map[int64][]int64{}
factFrom := map[int64]int64{}
for _, e := range edges {
switch e.Rel {
case db.RelDerivedFrom, db.RelSpawns: // upstream: derived_from (fact/finding/intent→intent) or spawns (origin fact→goal, legacy begin→intent)
parentsOf[e.To] = append(parentsOf[e.To], e.From)
case db.RelYields: // intent --yields--> fact/finding
yieldsOf[e.From] = append(yieldsOf[e.From], e.To)
factFrom[e.To] = e.From
}
}
// cold-digest §6: members folded into an active digest are shown via cold_digests
// (below), not the flat recent_* lists. `covered` maps member id → its digest id.
// §6 render-time revival check: a covered member that has become hot again (a new
// intent derived from it) must reappear this round — so `hidden` folds a member out
// only when it is covered AND still cold.
covered, _ := t.ts.CoveredMembers()
// Render-time hot set (ancestor of a live intent / fact under a live intent).
// §6 revival check: a covered member that revived (now hot) must NOT stay folded
// — hidden() only folds a member out when it is covered AND still cold. Computed
// every round (cheap for real graph sizes); nil map degrades safely.
var hotAtRender map[int64]bool
if cg, _, err := loadColdGraph(t.ts); err == nil {
hotAtRender = cg.hotSet()
}
hidden := func(id int64) bool { _, c := covered[id]; return c && !hotAtRender[id] }
const openIntentsCap = 30
fr, _ := t.ts.Frontier(openIntentsCap) // priority DESC, id ASC —— 优先级最高的前 N 条;真实总数见 frontier_open
out["open_intents"] = compactIntents(fr, parentsOf, yieldsOf)
all, _ := t.ts.ListByKind(db.KindIntent, 300)
var running, recentDone []*db.Node
for _, n := range all {
switch n.State {
case "running":
running = append(running, n)
case "done", "blocked", "exhausted":
if hidden(n.ID) {
continue // in a cold_digest and still cold — shown via cold_digests (§6.2)
}
recentDone = append(recentDone, n) // 最新在前(all 按 id 降序);折叠的已剔除,输出时截最新 N
}
}
out["running_intents"] = compactIntents(running, parentsOf, yieldsOf)
// done_intents_total:已结束意图(done/blocked/exhausted)总数,与 recent_done_intents
// 平行命名——后者只是它的最新窗口截断视图。两键并排即自描述:"看到的是 N/总数",
// 让 planner 去重时别把"没显示"当成"没派过",无需在提示词里另行解释。
if dt, err := t.ts.CountFinishedIntents(); err == nil {
out["done_intents_total"] = dt
}
// frontier_open:开放意图真实总数(open_intents 只是其中优先级最高的前 N 条截断视图)。
if fo, err := t.ts.CountOpenIntents(); err == nil {
out["frontier_open"] = fo
} else {
out["frontier_open"] = len(fr)
}
// findings (confirmed vulns) and facts (worker exploration results) are
// now distinct node kinds. recent_facts surfaces fact summaries (esp.
// negative results) so the planner sees them in one call; full content
// via node_detail(id).
vulnNodes, _ := t.ts.ListByKind(db.KindFinding, 1000)
factNodes, _ := t.ts.ListByKind(db.KindFact, 1000) // newest first
out["findings_total"] = len(vulnNodes) // 确认漏洞总数(目标判定看它);明细见 finding_list(最新一窗)
out["facts"] = len(factNodes) // 探索事实/结论数(含否定结论)
// findings 是任务里最高价值的产物 → 概览带最新一窗(≤10 条,vulnNodes 已按 id 降序即最新在前),
// 让 planner 每轮判目标时一眼看到最近确认的漏洞;全量/更早的用 list_findings 取。
// 每条只留 {id, summary, from_intent?}:from_intent 是产生本漏洞的意图。
// evidence/assets/vulnclass/severity/state 等仍可用 list_findings / node_detail(id) 取。
const findingListCap = 10
findingList := make([]map[string]any, 0, findingListCap)
for _, n := range vulnNodes {
if len(findingList) >= findingListCap {
break
}
var fp map[string]any
_ = json.Unmarshal(n.Payload, &fp)
m := map[string]any{"id": n.ID, "summary": fp["summary"]}
if from := factFrom[n.ID]; from > 0 {
m["from_intent"] = from // 本漏洞由哪个意图产生
}
findingList = append(findingList, m)
}
out["finding_list"] = findingList
// recent_facts:非折叠事实里最新的一窗(≤N,factNodes 按 id 降序即最新在前)。已折进
// digest 且仍冷的(hidden)走 cold_digests,不在此重复。每条 {id, summary, from_intent?,
// confidence?};evidence 等详情用 node_detail(id)。更早的用 list_facts 翻。
const recentFactsCap = 20
recentFacts := make([]map[string]any, 0, recentFactsCap)
for _, n := range factNodes {
if len(recentFacts) >= recentFactsCap {
break
}
if hidden(n.ID) {
continue // 已折进 digest 且仍冷 —— 见 cold_digests
}
m := compactNode(n)
if from := factFrom[n.ID]; from > 0 {
m["from_intent"] = from // 本事实由哪个意图产生
}
// confidence 带进概览:让规划者一眼看出哪条结论只是 inferred(尤其否定结论
// 别当铁案);evidence 较长,留给 node_detail(id)。
var fp map[string]any
if json.Unmarshal(n.Payload, &fp) == nil {
if c, ok := fp["confidence"].(string); ok && c != "" {
m["confidence"] = c
}
}
recentFacts = append(recentFacts, m)
}
out["recent_facts"] = recentFacts
// recent_done_intents:非折叠的已结束意图里最新的一窗(≤N,recentDone 已按 id 降序)。
// 更早的看 done_intents_total 计数 + node_detail(id)。
const recentDoneCap = 12
if len(recentDone) > recentDoneCap {
recentDone = recentDone[:recentDoneCap]
}
out["recent_done_intents"] = compactIntents(recentDone, parentsOf, yieldsOf)
// cold-digest §6.1: 折叠冷区的 digest body,按最新成员时间降序取前 N;被截的更旧 digest
// 只给裸 id(仍可 expand_digest 展开),避免冷区唯一出口被无限拉长。
const coldDigestsCap = 15
if cds, more := coldDigestsRecent(t.ts, coldDigestsCap); len(cds) > 0 {
out["cold_digests"] = cds // [{id, body, member_count}] —— 直接读 body (§6.1)
if len(more) > 0 {
out["cold_digests_more"] = more // 被截断的更旧 digest 的 id;用 expand_digest(id) 展开
}
}
// 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}
}
// 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
// frontier or get mistaken for locally claimable work.
out["related_tasks"] = t.relatedTaskOverviews()
// coverage:粗略的资产测试覆盖度参考——范围(task_scope)内的资产里,被 fact 碰过的
// 占比 + by_type(按类型的 总数/已测)。要看未测的具体资产由 agent 按需调 list_untested_assets 自行判断。仅任务上下文有。
// 资产覆盖度功能关闭时(coverageDisabled):只保留 host_count(目标主机数的感知信息),
// 丢弃 denominator/tested/pct/by_type/note 等覆盖度度量,避免污染上下文、也不诱导
// 已隐藏的 add_task_scope/list_untested_assets。
if t.as != nil && t.ts != nil && t.taskID > 0 {
{
m := map[string]any{}
if !t.coverageDisabled {
if cov, err := t.as.TaskCoverageWithSources(t.taskID); err == nil {
m["denominator"] = cov.Denominator
m["tested"] = cov.Tested
m["by_type"] = cov.ByType
m["note"] = "coverage资产测试覆盖度(包括接口等各种相关资产),粗略估计、仅供参考:包含当前任务与直接关联任务的 scope、事实锚点;关联 scope 只读。容器型资产/大量枚举会让它偏低,勿据此认为已测完;可用 add_task_scope 增补本任务范围、list_untested_assets 看未测资产【通常不调用list_untested_assets,按照任务推进即可】;"
if cov.Denominator == 0 {
m["pct"] = nil
m["status"] = "范围未锚定"
} else {
m["pct"] = cov.Pct
}
}
}
if hosts, err := t.as.HostsByTaskWithSources(t.taskID); err == nil {
// 只给主机总数,不再把 host 列表平铺进 graph_overview(大范围任务里那是每轮
// 都重复携带的大量字符串,对规划决策价值有限);具体主机按需 list_assets 查。
m["host_count"] = len(hosts)
}
if len(m) > 0 {
out["coverage"] = m
}
}
}
return out
}
func inheritedMap(m map[string]any, sourceTaskID int64) map[string]any {
m["source_task_id"] = sourceTaskID
m["inherited"] = true
return m
}
const (
relatedOverviewTotalTextRunes = 48_000
relatedOverviewMaxTextPerSource = 8_000
relatedOverviewMaxGoalsPerSource = 8
relatedOverviewMaxHintsPerSource = 6
relatedOverviewMaxFactsPerSource = 12
relatedOverviewMaxFindingsPerTask = 6
relatedOverviewMaxIntentsPerTask = 8
relatedOverviewMaxScopePerSource = 12
relatedOverviewMaxDigestsPerSource = 6
)
// overviewTextBudget bounds inherited prompt text while preserving a fair slice
// for every direct source. Full evidence remains available through the on-demand
// read tools, so truncation here does not discard persisted blackboard data.
type overviewTextBudget struct {
remaining int
truncated bool
}
func relatedOverviewBudgetForSources(sourceCount int) int {
if sourceCount <= 0 {
return 0
}
if sourceCount > db.MaxTaskSourceCount {
sourceCount = db.MaxTaskSourceCount
}
perSource := relatedOverviewTotalTextRunes / sourceCount
if perSource > relatedOverviewMaxTextPerSource {
perSource = relatedOverviewMaxTextPerSource
}
return perSource
}
func (b *overviewTextBudget) take(value any, fieldLimit int) string {
var text string
switch value := value.(type) {
case string:
text = strings.TrimSpace(value)
case nil:
return ""
default:
text = strings.TrimSpace(fmt.Sprint(value))
}
if text == "" {
return ""
}
if b.remaining <= 0 || fieldLimit <= 0 {
b.truncated = true
return ""
}
runes := []rune(text)
limit := fieldLimit
if limit > b.remaining {
limit = b.remaining
}
if len(runes) > limit {
b.truncated = true
if limit == 1 {
text = "…"
} else {
text = string(runes[:limit-1]) + "…"
}
runes = []rune(text)
}
b.remaining -= len(runes)
return text
}
func recentTerminalIntents(store *db.ExplorationStore, limit int) []*db.Node {
if limit <= 0 {
return []*db.Node{}
}
const batch = 300
cursor := int64(0)
out := make([]*db.Node, 0, limit)
for len(out) < limit {
page, more, err := store.ListByKindPage(db.KindIntent, cursor, batch)
if err != nil || len(page) == 0 {
break
}
for _, intent := range page {
switch intent.State {
case "done", "blocked", "exhausted", "stopped":
out = append(out, intent)
}
if len(out) >= limit {
break
}
}
if !more {
break
}
cursor = page[len(page)-1].ID
}
return out
}
// relatedTaskOverviews distills persistent blackboard state from direct source
// tasks. It intentionally reads each source's local store methods, never its own
// related sources, so inheritance is one level only.
func (t *ToolSet) relatedTaskOverviews() []map[string]any {
sources, err := t.ts.DirectSourceStores()
if err != nil {
return []map[string]any{}
}
if len(sources) > db.MaxTaskSourceCount {
sources = sources[:db.MaxTaskSourceCount]
}
perSourceTextBudget := relatedOverviewBudgetForSources(len(sources))
out := make([]map[string]any, 0, len(sources))
for _, source := range sources {
ts := source.Store
// §2 cross-task: render the source task's OWN folded view — fold out the
// members it has already folded, and surface its cold_digests read-only.
hidden := hiddenMembersFor(ts)
budget := overviewTextBudget{remaining: perSourceTextBudget}
item := map[string]any{
"source_task_id": source.Task.TaskID,
"inherited": true,
"task": map[string]any{
"description": budget.take(source.Task.Description, 800),
"goal": budget.take(source.Task.Goal, 800),
"status": source.Task.Status,
},
}
stats, statsErr := ts.Stats()
edges, _ := ts.Edges(5000)
parentsOf := map[int64][]int64{}
yieldsOf := map[int64][]int64{}
factFrom := map[int64]int64{}
for _, edge := range edges {
switch edge.Rel {
case db.RelDerivedFrom, db.RelSpawns:
parentsOf[edge.To] = append(parentsOf[edge.To], edge.From)
case db.RelYields:
yieldsOf[edge.From] = append(yieldsOf[edge.From], edge.To)
factFrom[edge.To] = edge.From
}
}
goals, _ := ts.ListByKind(db.KindGoal, relatedOverviewMaxGoalsPerSource)
goalSummary := make([]map[string]any, 0, len(goals))
for _, goal := range goals {
var payload map[string]any
_ = json.Unmarshal(goal.Payload, &payload)
goalSummary = append(goalSummary, inheritedMap(map[string]any{
"id": goal.ID, "state": goal.State, "text": budget.take(payload["text"], 400),
}, source.Task.TaskID))
}
item["goals"] = goalSummary
hints, _ := ts.ListByKind(db.KindHint, relatedOverviewMaxHintsPerSource)
hintSummary := make([]map[string]any, 0, len(hints))
for _, hint := range hints {
var payload map[string]any
_ = json.Unmarshal(hint.Payload, &payload)
hintSummary = append(hintSummary, inheritedMap(map[string]any{
"id": hint.ID, "state": hint.State, "text": budget.take(payload["text"], 400),
}, source.Task.TaskID))
}
item["hints"] = hintSummary
facts, _ := ts.ListByKind(db.KindFact, relatedOverviewMaxFactsPerSource)
findings, _ := ts.ListByKind(db.KindFinding, relatedOverviewMaxFindingsPerTask)
intentNodes, _ := ts.ListByKind(db.KindIntent, 300)
terminalIntent := make(map[int64]bool, len(intentNodes))
for _, intent := range intentNodes {
terminalIntent[intent.ID] = inheritedIntentSummaryState(intent.State)
}
item["facts"] = len(facts)
item["findings"] = len(findings)
if statsErr == nil {
item["facts"] = stats[db.KindFact]
item["findings"] = stats[db.KindFinding]
if stats[db.KindGoal] > len(goals) || stats[db.KindHint] > len(hints) ||
stats[db.KindFact] > len(facts) || stats[db.KindFinding] > len(findings) {
budget.truncated = true
}
}
recentFindings := make([]map[string]any, 0, len(findings))
for _, finding := range findings {
entry := inheritedMap(compactFinding(finding), source.Task.TaskID)
entry["summary"] = budget.take(entry["summary"], 400)
recentFindings = append(recentFindings, entry)
}
item["recent_findings"] = recentFindings
recentFacts := make([]map[string]any, 0, len(facts))
for _, fact := range facts {
if hidden(fact.ID) {
continue // folded into this source's cold_digests — shown there (§2/§6.2)
}
m := inheritedMap(compactNode(fact), source.Task.TaskID)
m["summary"] = budget.take(m["summary"], 400)
if from := factFrom[fact.ID]; from > 0 && terminalIntent[from] {
m["from_intent"] = from
}
var payload map[string]any
if json.Unmarshal(fact.Payload, &payload) == nil {
if confidence, ok := payload["confidence"].(string); ok && confidence != "" {
m["confidence"] = confidence
}
}
recentFacts = append(recentFacts, m)
}
item["recent_facts"] = recentFacts
recentDoneRaw := recentTerminalIntents(ts, relatedOverviewMaxIntentsPerTask)
recentDone := recentDoneRaw[:0] // in-place filter: drop this source's folded intents (§2)
for _, intent := range recentDoneRaw {
if hidden(intent.ID) {
continue
}
recentDone = append(recentDone, intent)
}
for _, intent := range recentDone {
intent.Inherited = true
intent.SourceTaskID = source.Task.TaskID
}
intentResults := compactIntents(recentDone, parentsOf, yieldsOf)
for i, intent := range recentDone {
intentResults[i]["summary"] = budget.take(intentResults[i]["summary"], 400)
acts, _, err := ts.ActivityPageForTerminalIntent(intent.ID, 0, 20)
if err != nil {
continue
}
var resultSummary, textFallback string
for _, activity := range acts {
switch activity.Kind {
case "result":
resultSummary = activity.Summary
case "text":
textFallback = activity.Summary
}
}
if resultSummary == "" {
resultSummary = textFallback
}
if resultSummary != "" {
intentResults[i]["result_summary"] = budget.take(resultSummary, 800)
}
}
item["recent_intent_results"] = intentResults
// §2 cross-task: the source task's folded cold region, read-only, newest-member
// first & capped like the current task's. Members (and overflow digests) are
// resolvable via expand_digest(id)/node_detail(id), which search source tasks.
if cds, more := coldDigestsRecent(ts, relatedOverviewMaxDigestsPerSource); len(cds) > 0 {
for _, cd := range cds {
cd["inherited"] = true
cd["source_task_id"] = source.Task.TaskID
}
item["cold_digests"] = cds
if len(more) > 0 {
item["cold_digests_more"] = more // 被截断的更旧 digest 的 id;expand_digest(id) 展开
}
}
if statsErr == nil {
item["node_stats"] = stats
}
if t.as != nil {
if scopeRows, err := t.as.ListTaskScope(source.Task.TaskID); err == nil && len(scopeRows) > 0 {
scopeCount := len(scopeRows)
if len(scopeRows) > relatedOverviewMaxScopePerSource {
scopeRows = scopeRows[:relatedOverviewMaxScopePerSource]
budget.truncated = true
}
scope := make([]map[string]any, 0, len(scopeRows))
for _, row := range scopeRows {
entry := map[string]any{"kind": row.Kind, "source": budget.take(row.Source, 300)}
switch {
case row.Domain != "":
entry["value"] = budget.take(row.Domain, 400)
case row.Net != "":
entry["value"] = budget.take(row.Net, 400)
case row.Value != "":
entry["value"] = budget.take(row.Value, 400)
case row.CompanyID != nil:
entry["company_id"] = *row.CompanyID
}
scope = append(scope, entry)
}
item["asset_scope"] = scope
item["asset_scope_count"] = scopeCount
}
if coverage, err := t.as.TaskCoverage(source.Task.TaskID, source.Task.ExplorationID); err == nil {
item["asset_coverage"] = map[string]any{
"denominator": coverage.Denominator,
"tested": coverage.Tested,
"pct": coverage.Pct,
"by_type": coverage.ByType,
}
}
}
if budget.truncated {
item["summary_truncated"] = true
}
out = append(out, item)
}
return out
}
func inheritedIntentSummaryState(state string) bool {
switch state {
case "done", "blocked", "exhausted", "stopped":
return true
default:
return false
}
}
// compactNode distills any exploration node to id + summary + state, dropping the
// big detail/evidence (fetch that on demand via node_detail).
func compactNode(n *db.Node) map[string]any {
var p map[string]any
_ = json.Unmarshal(n.Payload, &p)
m := map[string]any{"id": n.ID, "state": n.State, "summary": p["summary"]}
if n.Inherited {
inheritedMap(m, n.SourceTaskID)
}
return m
}
// compactFinding is compactNode plus the vuln-specific vulnclass/severity.
func compactFinding(n *db.Node) map[string]any {
var p map[string]any
_ = json.Unmarshal(n.Payload, &p)
m := map[string]any{"id": n.ID, "state": n.State, "summary": p["summary"]}
if n.Inherited {
inheritedMap(m, n.SourceTaskID)
}
if vc, ok := p["vulnclass"]; ok && vc != nil && vc != "" {
m["vulnclass"] = vc
}
if sv, ok := p["severity"]; ok && sv != nil && sv != "" {
m["severity"] = sv
}
return m
}
func (t *ToolSet) listFindings() actool.CoreTool {
return t.readExpTool("list_findings", "列本任务及直接关联任务的【确认漏洞】(紧凑:id+task_id+intent_id+vulnclass+severity+摘要+状态)。关联任务条目带 source_task_id/inherited=true 且只读。这里只含漏洞;普通探索事实用 list_facts,详情用 node_detail(id)。",
obj(map[string]any{}),
func(context.Context, json.RawMessage) (actool.Result, error) {
f, _ := t.ts.ListByKindWithSources(db.KindFinding, 500)
if err := t.ts.PopulateFindingTrafficIDs(f); err != nil {
return actool.Errorf(err.Error()), nil
}
intentOf, _ := t.ts.FindingIntentsWithSources() // finding id -> 产生它的 intent id
taskID := t.taskID
if taskID <= 0 {
taskID, _ = t.ts.TaskID()
}
out := make([]map[string]any, 0, len(f))
for _, n := range f {
m := compactFinding(n)
if n.FindingID > 0 {
m["finding_id"], m["finding_node_id"], m["traffic_count"] = n.FindingID, n.ID, n.TrafficCount
}
if n.Inherited {
m["task_id"] = n.SourceTaskID
} else {
m["task_id"] = taskID
}
if iid, ok := intentOf[n.ID]; ok {
m["intent_id"] = iid
}
out = append(out, m)
}
return jsonResult(out)
})
}
// factsPageSize is the default page size for list_facts. Facts pile up on long
// tasks; returning all of them at once (the old behaviour) could blow up the
// context, so default to the newest page and let the agent page/filter for more.
const factsPageSize = 20
func (t *ToolSet) listFacts() actool.CoreTool {
return t.readExpTool("list_facts", "分页列本任务及直接关联任务的【探索事实/结论】,最新在前(紧凑:id+摘要+状态,摘要过长会截断,全文用 node_detail(id))。参数均可选:limit(默认 20,上限 100)、before(游标,传上一页返回的 next_before 取更旧的一页;省略/0=最新一页)、q(按摘要关键词过滤)。返回 {facts, total, has_more, next_before}:total 是过滤后的总数,has_more=true 时用 next_before 继续翻页。关联任务条目带 source_task_id/inherited=true 且只读。漏洞看 list_findings。",
obj(map[string]any{
"limit": intp("返回条数,默认 20,上限 100"),
"before": intp("分页游标:只返回 id 小于该值的更旧事实;省略或 0 = 最新一页"),
"q": str("按事实摘要关键词过滤(不区分大小写);省略 = 不过滤"),
}),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
Limit int `json:"limit"`
Before int64 `json:"before"`
Q string `json:"q"`
}
_ = json.Unmarshal(in, &a)
limit := a.Limit
if limit <= 0 {
limit = factsPageSize
}
if limit > 100 {
limit = 100
}
f, hasMore, total, err := t.ts.ListByKindPageWithSources(db.KindFact, a.Before, limit, strings.TrimSpace(a.Q))
if err != nil {
return actool.Result{}, err
}
out := make([]map[string]any, 0, len(f))
for _, n := range f {
out = append(out, compactFact(n))
}
res := map[string]any{"facts": out, "total": total, "has_more": hasMore}
if hasMore && len(f) > 0 {
res["next_before"] = f[len(f)-1].ID // 传回它取下一页(更旧的)
}
return jsonResult(res)
})
}
// factSummaryMax caps a fact summary in list_facts output. Facts carry one-line
// conclusions, but nothing enforces brevity; a runaway summary must not bloat a
// whole page. Full text stays available via node_detail(id).
const factSummaryMax = 160
// compactFact is compactNode with the summary rune-capped for list_facts, so a
// page of facts stays bounded regardless of how long any single summary grew.
func compactFact(n *db.Node) map[string]any {
m := compactNode(n)
if s, ok := m["summary"].(string); ok && len([]rune(s)) > factSummaryMax {
m["summary"] = string([]rune(s)[:factSummaryMax]) + "…"
m["summary_truncated"] = true
}
return m
}
func (t *ToolSet) nodeDetail() actool.CoreTool {
return t.readExpTool("node_detail", "按 id 取本任务或直接关联任务的【探索图节点】完整内容。继承节点带 source_task_id/inherited=true 且只读。仅限 list_facts/list_findings/graph_overview 返回的探索节点 id;资产请用 list_assets/asset_neighbors。",
obj(map[string]any{"id": idp("探索图节点 id(非资产 id)")}, "id"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
ID json.RawMessage `json:"id"`
}
_ = json.Unmarshal(in, &a)
id := pid(a.ID)
if id <= 0 {
return actool.Errorf("id 必填"), nil
}
n, err := t.ts.GetNodeWithSources(id)
if err != nil {
return actool.Errorf(err.Error()), nil
}
if n == nil {
return actool.Errorf(fmt.Sprintf("未找到探索节点 %d。若你想查的是资产,请用 list_assets / asset_neighbors(资产与探索节点是不同的 id 空间,资产 id 不能传给 node_detail)。", id)), nil
}
if err := t.ts.PopulateFindingTrafficIDs([]*db.Node{n}); err != nil {
return actool.Errorf(err.Error()), nil
}
return jsonResult(n) // full payload incl. detail / evidence, plus explicit finding IDs
})
}
// --- planner write tools ---
// intentItem 是 add_intent 批量/单条的一条探索方向。
type intentItem struct {
Summary string `json:"summary"`
AssetIDs []json.RawMessage `json:"asset_ids"`
ParentIDs []json.RawMessage `json:"parent_ids"`
Priority int `json:"priority"`
}
// addOneIntent 创建一条意图节点并连上游血缘,返回 id。
// 约束:意图只能锚在已确认知识上——每个 parent_id 必须是已存在的 fact/finding
// 节点(不能挂在别的意图/目标/提示上)。顶层全新方向留空 parent_ids,兜底连 origin fact。
// 这样"每个意图都连到 fact 节点、且是发现驱动而非凭空规划"从创建路径上被强制。
func (t *ToolSet) addOneIntent(it intentItem) (int64, error) {
if strings.TrimSpace(it.Summary) == "" {
return 0, fmt.Errorf("summary 不能为空")
}
// 先校验锚点(建节点前,避免坏锚点留下孤儿意图)。
parents := pidList(it.ParentIDs)
for _, pidv := range parents {
n, err := t.ts.GetNodeWithSources(pidv)
if err != nil || n == nil {
return 0, fmt.Errorf("parent_id %d 不存在于本任务或直接关联任务:parent_ids 必须是已存在的【事实(fact)/发现(finding)】节点 id;顶层全新方向请留空 parent_ids", pidv)
}
if n.Kind != db.KindFact && n.Kind != db.KindFinding {
return 0, fmt.Errorf("parent_id %d 是 %q 节点,不能作为意图锚点:意图只能锚在已确认的【事实(fact)/发现(finding)】上,不能挂在意图/目标/提示上;顶层全新方向请留空 parent_ids", pidv, n.Kind)
}
}
priority := it.Priority
if priority == 0 {
priority = 5
}
anchors := pidList(it.AssetIDs)
// 资产拦截:意图绑定的资产若命中系统资产拦截规则,则禁止下发该意图。
if t.as != nil && len(anchors) > 0 {
hits, err := t.as.CheckAssetsIntercept(t.taskID, anchors)
if err != nil {
return 0, fmt.Errorf("资产拦截校验失败:%w", err)
}
if len(hits) > 0 {
var b strings.Builder
fmt.Fprintf(&b, "意图「%s」绑定的资产未通过测试范围校验,请停止对相关资产进行测试:", it.Summary)
for _, h := range hits {
fmt.Fprintf(&b, "\n - %s", h.Describe())
}
return 0, fmt.Errorf("%s", b.String())
}
}
payload := map[string]any{"summary": it.Summary}
if len(anchors) > 0 {
payload["asset_ids"] = anchors
}
id, err := t.ts.AddIntent(payload, priority, anchors, "planner")
if err != nil {
return 0, err
}
// upstream lineage: link each (validated) fact/finding parent → this intent, so
// "multiple facts combine into one new intent" is expressible.
for _, parent := range parents {
_ = t.ts.Link(parent, db.RelDerivedFrom, id)
}
// a top-level intent (no explicit parent) connects to the origin fact, so every
// intent still traces back to a fact node — at task start the only fact is the
// origin, and the first intents derive from it.
if len(parents) == 0 {
if origin, _ := t.ts.OriginFactID(); origin > 0 {
_ = t.ts.Link(origin, db.RelDerivedFrom, id)
}
}
return id, nil
}
func (t *ToolSet) addIntent() actool.CoreTool {
return t.writeExpTool("add_intent", "生成【探索方向】写入 frontier,并连入探索链路。意图是开放的探索方向,不是固定类型——用 summary 一句话自由描述要探索/验证/利用什么。\n"+
"★优先批量:一轮筛出的多个新方向放进 intents 数组一次提交(比逐条调用省往返)。返回 ids 数组,与 intents 等长同序(失败项 id=0,详情见 errors)。单条则省略 intents 直接给顶层 summary。",
obj(map[string]any{
"intents": map[string]any{"type": "array", "description": "【优先用这个】要新增的探索方向数组,按顺序处理。每个元素字段同下方顶层字段(summary/asset_ids/parent_ids/priority)。返回 ids 与本数组等长、同序。", "items": map[string]any{"type": "object"}},
"summary": str("[单条] 一句话描述这个探索方向:做什么+为什么。已写清方向即可,不依赖资产 id。"),
"asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "本方向要测试/攻击的【目标资产 id】(**尽量传**,0/1/多个;是 list_assets 返回的资产 id,不是探索节点 id):这条探索方向针对哪些资产(站点/接口/参数/主机等)。只要方向围绕某些具体资产就务必传上——它是「这条探索打哪些目标」的结构化标记,用于覆盖去重、把意图连入资产链路。仅当纯全局侦察、确实没有具体目标资产时才留空。"},
"parent_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "上游锚点 id(可选,0/1/多个):本方向由哪些【已确认的事实(fact)/发现(finding)】综合得出。**只能填已存在的 fact/finding 节点 id,不能填意图/目标/提示**——意图必须锚在已确认知识上,发现驱动而非凭空规划。多个事实共同产生一个新意图就传多个;顶层全新侦察方向请留空(会自动挂到任务起点 origin fact)。"},
"priority": intp("优先级 0-10,默认5"),
}),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
Intents []intentItem `json:"intents"`
intentItem // 单条模式:顶层 summary/asset_ids/parent_ids/priority
}
_ = json.Unmarshal(in, &a)
batch := len(a.Intents) > 0
items := a.Intents
if !batch {
items = []intentItem{a.intentItem}
}
ids := make([]int64, len(items))
errs := map[string]string{}
createdAny := false
for i, it := range items {
id, err := t.addOneIntent(it)
if err != nil {
errs[strconv.Itoa(i)] = err.Error()
continue
}
ids[i] = id
createdAny = true
}
// 人经主 agent 直投意图 → 若任务已 done(无 open 目标的 goalless 分支),把它
// 拉回 running,worker 才能领这条意图执行。resumeTask 仅由主 agent 的 Chat 接入
// (SetResumeTask);planner 的 ToolSet 为 nil,故 planner 自己调 add_intent 时此段
// no-op,不影响其正常产意图。意图节点已在上面建好(open),复活时不会被误判抽干。
if createdAny && t.resumeTask != nil {
t.resumeTask()
}
if !batch { // 单条:保持原返回
if e, bad := errs["0"]; bad {
return actool.Errorf(e), nil
}
return actool.Text(fmt.Sprintf("intent created: %d", ids[0])), nil
}
out := map[string]any{"ids": ids}
if len(errs) > 0 {
out["errors"] = errs
}
return jsonResult(out)
})
}
func (t *ToolSet) listGoals() actool.CoreTool {
return t.readExpTool("list_goals", "列出本任务的目标节点及其状态(open/met),用于判断是否达成。",
obj(map[string]any{}),
func(context.Context, json.RawMessage) (actool.Result, error) {
g, _ := t.ts.ListByKind(db.KindGoal, 100)
return jsonResult(g)
})
}
func (t *ToolSet) proveGoal() actool.CoreTool {
return t.writeExpTool("prove_goal", "当你判断某个发现/事实证明了某个目标达成时调用:把证据节点连到目标节点,并标记目标 met。",
obj(map[string]any{
"goal_id": idp("目标节点 id"),
"evidence_id": idp("证明它的发现/事实节点 id"),
"reason": str("为什么这个证据满足该目标"),
}, "goal_id", "evidence_id"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
GoalID json.RawMessage `json:"goal_id"`
EvidenceID json.RawMessage `json:"evidence_id"`
Reason string `json:"reason"`
}
_ = json.Unmarshal(in, &a)
goal, ev := pid(a.GoalID), pid(a.EvidenceID)
if goal == 0 || ev == 0 {
return actool.Errorf("goal_id 和 evidence_id 必填"), nil
}
goalNode, err := t.ts.GetNode(goal)
if err != nil || goalNode == nil || goalNode.Kind != db.KindGoal {
return actool.Errorf("goal_id 必须是本任务的目标节点(关联任务目标只读)"), nil
}
evidenceNode, err := t.ts.GetNodeWithSources(ev)
if err != nil || evidenceNode == nil || (evidenceNode.Kind != db.KindFact && evidenceNode.Kind != db.KindFinding) {
return actool.Errorf("evidence_id 必须是本任务或直接关联任务的事实/漏洞节点"), nil
}
_ = t.ts.Link(ev, db.RelProves, goal)
_ = t.ts.SetNodeState(goal, "met")
// 每标记一个目标 met,就检查本任务是否【所有目标】都已 met;若是,自动判定
// 任务完成(置 GoalMet),无需再依赖模型显式调 goal_met。
if goals, err := t.ts.ListByKind(db.KindGoal, 1000); err == nil && len(goals) > 0 {
allMet := true
for _, g := range goals {
if g.State != "met" {
allMet = false
break
}
}
if allMet {
t.GoalMet = true
t.Reason = fmt.Sprintf("所有 %d 个目标均已 met(最后由 goal %d 触发)", len(goals), goal)
return actool.Text(fmt.Sprintf("goal %d marked met;本任务所有目标均已达成,任务自动判定完成", goal)), nil
}
}
return actool.Text(fmt.Sprintf("goal %d marked met", goal)), nil
})
}
func (t *ToolSet) goalMet() actool.CoreTool {
return writeTool("goal_met", "【立即结束整个任务】——仅当你确认任务的【全部目标都已真正达成、整体收官】时才调(注意是任务【整体】完成;仅仅达成了其中某一个目标/某一个 flag/某一个漏洞【不算】——那种情况用 prove_goal 标记该目标即可)。⚠️它不是用来“结束本轮规划”的:本轮没有新意图要派、或在等 worker 产出,都【直接结束本轮即可,不要调本工具】(0 个意图是完全正常的)。正常判定优先用 prove_goal 逐个证明目标;goal_met 只是绕过逐个证明、直接从全局收官的手段。",
obj(map[string]any{"reason": str("达成理由(必须是目标真正达成的证据,不能是“本轮无新方向”这类结束本轮的理由)")}, "reason"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct{ Reason string }
_ = json.Unmarshal(in, &a)
t.GoalMet = true
t.Reason = a.Reason
return actool.Text("acknowledged: goal marked met"), nil
})
}
// --- worker write tools ---
func (t *ToolSet) addFinding() actool.CoreTool {
return writeTool("report_finding", "记录确认的漏洞,用 evidence 提供命令输出、日志等可验证证据。任务上下文传当前 intent_id。返回的 finding_id 是独立漏洞记录 ID,finding_node_id 是探索节点 ID(第一行保留该节点编号)。", obj(map[string]any{
"vulnclass": str("漏洞类别"), "name": str("漏洞名称"), "severity": str("critical|high|medium|low"), "summary": str("发现摘要"),
"intent_id": idp("当前任务的意图 id"), "asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "受影响资产 id"},
"evidence": str("证据/PoC 文本"),
"evidence_hint_id": idp("可选:本任务中对应此漏洞的提示节点 ID,自动携带其结构化 traffic_refs;不能引用继承提示或其他漏洞的提示"),
"traffic_refs": map[string]any{"type": "array", "description": "可选;HTTP/HTTPS 漏洞先检索并逐条核实请求/响应确实支持漏洞结论,再按复现顺序填写真实 ID。TCP 等非 HTTP 漏洞、未采集或找不到确切记录时省略或传 [],不阻止上报;可在 evidence 说明原因并提供其他可验证证据。不要猜测 ID、按域名/时间推定关联或仅为补包重复探测。用途 baseline 正常对照 / proof 漏洞证明 / verification 补充验证 / supporting 辅助证据。",
"items": obj(map[string]any{"traffic_id": str("traffic_search 返回的真实流量 ID"), "role": map[string]any{"type": "string", "enum": []string{"baseline", "proof", "verification", "supporting"}}, "note": str("该流量如何支持漏洞结论")}, "traffic_id")},
}, "vulnclass", "severity", "summary"), func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
VulnClass, Name, Severity, Summary, Evidence string
IntentID json.RawMessage `json:"intent_id"`
AssetIDs []json.RawMessage `json:"asset_ids"`
TrafficRefs []db.TrafficRef `json:"traffic_refs"`
EvidenceHintID json.RawMessage `json:"evidence_hint_id"`
}
if err := json.Unmarshal(in, &a); err != nil {
return actool.Errorf(err.Error()), nil
}
if t.ts == nil {
return actool.Errorf("report_finding 需要任务上下文;平台对话请通过 add_task_hint 向对应任务交接漏洞,并在提示中携带已有的 traffic_refs,由任务 Agent 登记。已登记漏洞可用 bind_finding_traffic 补绑。"), nil
}
// Auto-binding off: ignore the evidence params instead of rejecting the call.
// stripTrafficParameters already removes them from the advertised schema, but
// models routinely emit fields anyway — failing here would discard a confirmed
// finding over a stray parameter. The success path below reports evidence_status
// "not_bound" with the "已关闭,可在页面人工关联" note, which is what the caller needs.
if !findingTrafficBindingEnabled() {
a.TrafficRefs, a.EvidenceHintID = nil, nil
}
if len(a.EvidenceHintID) > 0 && pid(a.EvidenceHintID) <= 0 {
return actool.Errorf("evidence_hint_id 必须为有效的提示节点 ID;无交接提示时省略"), nil
}
refs, err := t.findingRefsFromHint(pid(a.EvidenceHintID), a.TrafficRefs)
if err != nil {
return actool.Errorf(err.Error()), nil
}
input := db.RecordFindingInput{TaskID: t.taskID, ExplorationID: t.ts.ID(), IntentID: pid(a.IntentID), VulnClass: a.VulnClass, Name: a.Name, Severity: a.Severity, Summary: a.Summary, Evidence: a.Evidence, Worker: t.worker, AssetIDs: pidList(a.AssetIDs)}
var recorded *db.RecordedFinding
if t.findingRecorder != nil {
recorded, err = t.findingRecorder.Record(ctx, input, refs)
} else if len(refs) > 0 {
return actool.Errorf("流量证据存储不可用;未登记漏洞"), nil
} else {
recorded, err = t.ts.RecordFinding(ctx, input)
}
if err != nil {
return actool.Errorf(err.Error()), nil
}
if t.notifyFinding != nil {
iid := input.IntentID
if iid <= 0 {
iid = t.ownerNode
}
t.notifyFinding(iid, a.Summary)
} else if t.notify != nil {
t.notify()
}
t.writes.Findings++
// Keep the first line's node-ID contract for existing reporter triggers.
for i := range recorded.Traffic.Bindings {
recorded.Traffic.Bindings[i].Snapshot.ReqHead = ""
recorded.Traffic.Bindings[i].Snapshot.RespHead = ""
}
result := struct {
*db.RecordedFinding
EvidenceStatus string `json:"evidence_status"`
EvidenceNote string `json:"evidence_note,omitempty"`
}{RecordedFinding: recorded, EvidenceStatus: "bound"}
if len(recorded.Traffic.Bindings) == 0 {
result.EvidenceStatus = "not_bound"
result.EvidenceNote = "漏洞已保存,未绑定流量。TCP/无包情形可正常继续;若已有核实的 HTTP 流量,请用可用的 bind_finding_traffic 或漏洞页面补绑,再完成证据交接。不要重复创建漏洞。"
if !findingTrafficBindingEnabled() {
result.EvidenceNote = "漏洞已保存。Agent 自动绑定流量已关闭,可在页面人工关联流量。"
}
}
raw, _ := json.Marshal(result)
return actool.Text(fmt.Sprintf("finding recorded: %d\n%s", recorded.NodeID, raw)), nil
})
}
// recordFact writes a general exploration RESULT/conclusion (not a vuln, not a
// new asset) into the EXPLORATION graph, chained to the intent that produced it.
// This is the home for observations and — importantly — negative results
// ("port closed", "param not injectable", "no login found"). Such conclusions
// must NOT be stuffed into the asset graph via upsert_asset.
// factItem 是 record_fact 批量/单条的一条事实。
type factItem struct {
Summary string `json:"summary"`
Detail string `json:"detail"`
Evidence string `json:"evidence"` // 一行关键证据(命令+关键输出行),支撑结论、便于事后核对
Confidence string `json:"confidence"` // observed(直接看到)| inferred(据现象推断)
IntentID json.RawMessage `json:"intent_id"`
AssetIDs []json.RawMessage `json:"asset_ids"`
}
// recordOneFact 写一条 fact 节点并连到意图(intent→yields→fact)。defaultIntent 为
// 批量时的默认意图(本条未给 intent_id 时用)。
func (t *ToolSet) recordOneFact(it factItem, defaultIntent int64) (int64, error) {
if strings.TrimSpace(it.Summary) == "" {
return 0, fmt.Errorf("summary 不能为空")
}
payload := map[string]any{"summary": it.Summary}
if it.Detail != "" {
payload["detail"] = it.Detail
}
if e := strings.TrimSpace(it.Evidence); e != "" {
payload["evidence"] = e
}
if c := strings.TrimSpace(it.Confidence); c != "" {
payload["confidence"] = c
}
intent := pid(it.IntentID)
if intent <= 0 {
intent = defaultIntent
}
if intent > 0 {
node, err := t.ts.GetNode(intent)
if err != nil || node == nil || node.Kind != db.KindIntent {
return 0, fmt.Errorf("intent_id 必须是本任务的意图(关联任务意图只读)")
}
}
// a fact is its OWN node kind (distinct from a vuln finding).
id, err := t.ts.AddNode(db.KindFact, payload, 5, "confirmed", t.worker, pidList(it.AssetIDs))
if err != nil {
return 0, err
}
if intent > 0 {
_ = t.ts.Link(intent, db.RelYields, id) // chain: intent -> fact
}
t.writes.Facts++
return id, nil
}
func (t *ToolSet) recordFact() actool.CoreTool {
return t.writeExpTool("record_fact", "把探索【事实/结论】写入探索图,连到产生它的意图(intent_id)。用于记录探索结果——包括指纹/枚举等【正向结论】,和'端口关闭'/'参数不可注入'/'未发现登录入口'等【否定结论】。\n"+
"⚠️一次探索的多个观察要【汇总成一条事实】,不要拆成多条,可以合并成一条事实的就尽量用一条事实表示:summary=对本次结论的总结性一句话,detail=相关细节(可含多个具体项)。例:指纹意图→一条事实 {summary:'识别了 X 站点的技术栈与响应特征', detail:'nginx 1.25 / Vue3 / 200 / title=.. / body_len=..'},而不是状态码、指纹、标题各记一条。一条意图通常只产出一条事实,拆太碎会让图谱无限膨胀。\n"+
"★facts 数组用于一次写多条【彼此不同】的结论(每条可省略 intent_id,默认用顶层 intent_id)。返回 ids 数组,与 facts 等长同序。\n"+
"⚠️只写你在工具输出里【真实看到】的结论,不要脑补。evidence 与 confidence 用来防止不准确的结论污染图谱:\n"+
" · evidence=支撑本结论的【一行】关键证据(命令+最能证明的那一两行输出),**务必简洁**——细节已在 detail,这里不要再粘大段输出。\n"+
" · confidence=observed(输出里直接看到)| inferred(据现象推断)。\n"+
" · **否定类结论**(不可注入/端口关闭/未发现入口等)只写\"观察 + 试探性读法\"——陈述你实际看到什么,方向是否放弃由规划者综合全局定;务必给 evidence,手段没穷尽或证据弱(含只探一次、看起来像)标 inferred,确已穷尽且直接看到才标 observed。",
obj(map[string]any{
"facts": map[string]any{"type": "array", "description": "【有多条不同结论时用】事实数组,元素字段同下方顶层字段(summary/detail/evidence/confidence/intent_id/asset_ids);省略 intent_id 则用顶层 intent_id。返回 ids 与本数组等长、同序。", "items": map[string]any{"type": "object"}},
"summary": str("对本次探索结论的【总结性一句话】(是对 detail 的概括)"),
"intent_id": idp("产生本事实的意图 id(你领到的意图;批量时作为各条默认)"),
"detail": str("本事实的相关细节:把这次探索的多个观察事实都写进这里"),
"evidence": str("【一行】关键证据:命令 + 最能证明结论的那一两行输出。务必简洁,不要粘大段输出(细节放 detail)。"),
"confidence": str("observed(输出里直接看到)| inferred(据现象推断)。否定结论务必如实标注。"),
"asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "相关资产 id(可选,0/1/多个):该事实涉及哪些资产"},
}),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
Facts []factItem `json:"facts"`
factItem // 单条模式 + 批量默认 intent_id
}
_ = json.Unmarshal(in, &a)
batch := len(a.Facts) > 0
items := a.Facts
if !batch {
items = []factItem{a.factItem}
}
defaultIntent := pid(a.factItem.IntentID) // 顶层 intent_id = 批量默认
ids := make([]int64, len(items))
errs := map[string]string{}
for i, it := range items {
id, err := t.recordOneFact(it, defaultIntent)
if err != nil {
errs[strconv.Itoa(i)] = err.Error()
continue
}
ids[i] = id
}
if !batch { // 单条:保持原返回
if e, bad := errs["0"]; bad {
return actool.Errorf(e), nil
}
return actool.Text(fmt.Sprintf("fact recorded: %d", ids[0])), nil
}
out := map[string]any{"ids": ids}
if len(errs) > 0 {
out["errors"] = errs
}
return jsonResult(out)
})
}
type hintItem struct {
Text string `json:"text"`
AssetIDs []json.RawMessage `json:"asset_ids"`
TrafficRefs []db.TrafficRef `json:"traffic_refs"`
}
// addOneHint 挂一条 hint 节点(active/human)到探索图,可锚定资产,返回 id。
func (t *ToolSet) addOneHint(it hintItem) (int64, error) {
if len(it.TrafficRefs) > 0 && !findingTrafficBindingEnabled() {
return 0, fmt.Errorf("Agent 自动绑定流量已关闭,未保存携带 traffic_refs 的提示;可在系统设置开启,或仅交接文字")
}
if strings.TrimSpace(it.Text) == "" {
return 0, fmt.Errorf("text 不能为空")
}
var anchors []int64
for _, raw := range it.AssetIDs {
if tid := pid(raw); tid > 0 {
anchors = append(anchors, tid)
}
}
refs, err := db.NormalizeTrafficRefs(it.TrafficRefs)
if err != nil {
return 0, err
}
payload := map[string]any{"text": it.Text}
if len(refs) > 0 {
payload["traffic_refs"] = refs
}
// 唤醒 planner 不在此处逐条做——由 addHint 在整批写完后统一触发一次(带上提示文本),
// 避免一次 add_hint 多条提示逐条刷屏 planner 的触发行。
return t.ts.AddNode(db.KindHint, payload, 0, "active", "human", anchors)
}
type goalItem struct {
Text string `json:"text"`
VulnClass string `json:"vulnclass"`
}
// addOneGoal 挂一条 goal 节点(open)到探索图:连到任务根(origin fact,rel spawns)。
// origin 取 t.worker(缺省 system):goals 拆解器写入的记 "goals"、主 agent 运行时记
// "human"。唤醒 planner 由 setGoals 在整批写完后统一做(见下),这里只负责落库。
func (t *ToolSet) addOneGoal(it goalItem) (int64, error) {
text := strings.TrimSpace(it.Text)
if text == "" {
return 0, fmt.Errorf("text 不能为空")
}
payload := map[string]any{"text": text}
if vc := strings.TrimSpace(it.VulnClass); vc != "" {
payload["vulnclass"] = vc
}
origin := t.worker
if origin == "" {
origin = "system"
}
id, err := t.ts.AddNode(db.KindGoal, payload, 0, "open", origin, nil)
if err != nil {
return 0, err
}
if of, _ := t.ts.OriginFactID(); of > 0 && id > 0 {
_ = t.ts.Link(of, db.RelSpawns, id) // goals descend from the task root (origin fact)
}
return id, nil
}
// setGoals 给【本任务】新增探索目标(goal 节点)。既是目标拆解器的提交工具,也是主
// agent 运行时补目标的工具——同一个受管工具,可在 web 端改描述/schema、按 agent 绑定。
func (t *ToolSet) setGoals() actool.CoreTool {
return writeTool("set_goals",
"给【本任务】新增探索目标(goal)。目标=最终可交付/可核验的结果,不是攻击步骤或侦察动作。\n"+
"★优先批量:多个目标放进 goals 数组一次提交,返回 ids 与之等长同序(失败项 id=0,详情见 errors)。单条则省略 goals 直接给顶层 text。\n"+
"vulnclass 可选:对应漏洞类(如 SQLi/IDOR),业务逻辑类目标留空。目标是否达成由系统判定标记 met,本工具只负责新增。",
obj(map[string]any{
"goals": map[string]any{"type": "array", "description": "【优先用这个】要新增的目标数组,按顺序处理。每个元素:text(必填,一个独立可验证的最终目标)+ vulnclass(可选)。返回 ids 与本数组等长、同序。", "items": map[string]any{"type": "object"}},
"text": str("[单条] 一个独立可验证的最终目标"),
"vulnclass": str("[单条] 对应漏洞类(若明确),如 SQLi/IDOR;业务逻辑目标可留空"),
}),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
if t.ts == nil {
return actool.Errorf("set_goals 未启用: ExplorationStore 未初始化"), nil
}
var a struct {
Goals []goalItem `json:"goals"`
goalItem // 单条模式:顶层 text/vulnclass
}
_ = json.Unmarshal(in, &a)
batch := len(a.Goals) > 0
items := a.Goals
if !batch {
items = []goalItem{a.goalItem}
}
ids := make([]int64, len(items))
errs := map[string]string{}
var addedTexts []string
for i, it := range items {
id, err := t.addOneGoal(it)
if err != nil {
errs[strconv.Itoa(i)] = err.Error()
continue
}
ids[i] = id
addedTexts = append(addedTexts, strings.TrimSpace(it.Text))
}
if len(addedTexts) > 0 {
// 唤醒 planner(整批一次)。优先 notifyGoal:一次 set_goals 记一条「人新增了
// N 个目标:…」触发,不逐条刷屏;拆解器/worker 无此回调 → 退回纯 notify(拆解器
// round-0 连 notify 也没接,即无操作,因为此时 planner 尚未启动)。
switch {
case t.notifyGoal != nil:
t.notifyGoal(addedTexts)
case t.notify != nil:
t.notify()
}
// 主 agent 运行时新增目标 → 把已完成/暂停的任务拉回 running 继续跑(终态门会
// 吞掉普通 notify,必须显式复活)。仅 mainagent 接了此回调;拆解器/worker 为 nil。
if t.resumeTask != nil {
t.resumeTask()
}
}
if !batch { // 单条:保持原返回
if e, bad := errs["0"]; bad {
return actool.Errorf(e), nil
}
return actool.Text(fmt.Sprintf("goal added: %d", ids[0])), nil
}
out := map[string]any{"ids": ids}
if len(errs) > 0 {
out["errors"] = errs
}
return jsonResult(out)
})
}
type constraintItem struct {
Text string `json:"text"`
Type string `json:"type"` // allow | deny
}
// addOneConstraint 落一条操作约束到 task_constraints。origin 取 t.worker(缺省 system):
// 拆解器写 "goals"、主 agent 写 "human"。
func (t *ToolSet) addOneConstraint(it constraintItem) (int64, error) {
text := strings.TrimSpace(it.Text)
if text == "" {
return 0, fmt.Errorf("text 不能为空")
}
kind := strings.TrimSpace(strings.ToLower(it.Type))
if kind == "" {
kind = "deny" // 默认按禁止处理:未标注类型时更保守
}
if kind != "allow" && kind != "deny" {
return 0, fmt.Errorf("type 必须是 allow 或 deny")
}
return t.ts.AddConstraint(kind, text, t.worker)
}
// setConstraints 给【本任务】新增操作约束(allow=允许做什么 / deny=禁止做什么)。既是目标
// 拆解器 round-0 抽约束的提交工具,也是主 agent 运行时补约束的工具——同一受管工具,可在 web
// 端改描述/schema、按 agent 绑定。约束会被注入 planner/worker 的系统提示以约束探索边界。
func (t *ToolSet) setConstraints() actool.CoreTool {
return writeTool("set_constraints",
"给【本任务】新增操作约束,用来框定探索边界:type=allow(允许做的操作)或 deny(禁止做的操作)。\n"+
"约束=对『可以/不可以做哪些操作』的规定(如『仅测当前端口,不扫其他端口』『禁止对生产库做写操作』『只允许被动侦察』),不是目标、也不是攻击步骤。\n"+
"★优先批量:多条放进 constraints 数组一次提交,返回 ids 与之等长同序(失败项 id=0,详情见 errors)。单条则省略 constraints 直接给顶层 text/type。\n"+
"只登记任务目标/描述里【明确写出】的约束,不要臆造;拿不准类型时用 deny(更保守)。",
obj(map[string]any{
"constraints": map[string]any{"type": "array", "description": "【优先用这个】要新增的约束数组,按顺序处理。每个元素:text(必填,一条约束)+ type(allow|deny)。返回 ids 与本数组等长、同序。", "items": map[string]any{"type": "object"}},
"text": str("[单条] 一条操作约束的内容"),
"type": str("[单条] allow(允许)或 deny(禁止);缺省按 deny 处理"),
}),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
if t.ts == nil {
return actool.Errorf("set_constraints 未启用: ExplorationStore 未初始化"), nil
}
var a struct {
Constraints []constraintItem `json:"constraints"`
constraintItem // 单条模式:顶层 text/type
}
_ = json.Unmarshal(in, &a)
batch := len(a.Constraints) > 0
items := a.Constraints
if !batch {
items = []constraintItem{a.constraintItem}
}
ids := make([]int64, len(items))
errs := map[string]string{}
for i, it := range items {
id, err := t.addOneConstraint(it)
if err != nil {
errs[strconv.Itoa(i)] = err.Error()
continue
}
ids[i] = id
}
if !batch { // 单条:保持简单返回
if e, bad := errs["0"]; bad {
return actool.Errorf(e), nil
}
return actool.Text(fmt.Sprintf("constraint added: %d", ids[0])), nil
}
out := map[string]any{"ids": ids}
if len(errs) > 0 {
out["errors"] = errs
}
return jsonResult(out)
})
}
func (t *ToolSet) addHint() actool.CoreTool {
return t.writeExpTool("add_hint", "把人类/主 agent 的战略提示挂到探索图,规划者下次生成意图时会读到它。\n"+
"★优先批量:多条提示放进 hints 数组一次提交(比逐条调用省往返)。返回 ids 数组,与 hints 等长同序(失败项 id=0,详情见 errors)。单条则省略 hints 直接给顶层 text。",
obj(map[string]any{
"hints": map[string]any{"type": "array", "description": "【优先用这个】要新增的提示数组,按顺序处理。每个元素字段同下方顶层字段(text/asset_ids/traffic_refs)。返回 ids 与本数组等长、同序。", "items": obj(map[string]any{"text": str("提示内容"), "asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}}, "traffic_refs": HintTrafficSchema()})},
"text": str("[单条] 提示内容,如'重点挖认证后接口'"),
"traffic_refs": HintTrafficSchema(),
"asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "锚定的资产 id(可选,0/1/多个)"},
}),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
Hints []hintItem `json:"hints"`
hintItem // 单条模式:顶层 text/asset_ids
}
_ = json.Unmarshal(in, &a)
batch := len(a.Hints) > 0
items := a.Hints
if !batch {
items = []hintItem{a.hintItem}
}
ids := make([]int64, len(items))
errs := map[string]string{}
var addedTexts []string
for i, it := range items {
id, err := t.addOneHint(it)
if err != nil {
errs[strconv.Itoa(i)] = err.Error()
continue
}
ids[i] = id
addedTexts = append(addedTexts, strings.TrimSpace(it.Text))
}
if len(addedTexts) > 0 {
// 唤醒 planner(整批一次)。优先 notifyHint:一次 add_hint 记一条「人新增了
// N 条战略提示:…」触发,让 planner 明确"本轮由新增 hint 触发"并看到提示内容;
// 未接该回调时退回纯 notify(bare wake,hint 仍折在图里供其自行读取)。
switch {
case t.notifyHint != nil:
t.notifyHint(addedTexts)
case t.notify != nil:
t.notify()
}
}
if !batch { // 单条:保持原返回
if e, bad := errs["0"]; bad {
return actool.Errorf(e), nil
}
return actool.Text(fmt.Sprintf("hint added: %d", ids[0])), nil
}
out := map[string]any{"ids": ids}
if len(errs) > 0 {
out["errors"] = errs
}
return jsonResult(out)
})
}
// killWorkTool lets the planner terminate a single running work (by intent id).
func (t *ToolSet) killWorkTool() actool.CoreTool {
return t.writeExpTool("kill_work", "终止一条正在运行的意图(work)。用于叫停跑偏/无意义的探索;被终止的意图标记为 stopped,不再自动重领。先用 get_worker_output 看看它在干嘛再决定。",
obj(map[string]any{"intent_id": idp("要终止的意图 id(= work 句柄)")}, "intent_id"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
if t.killWork == nil {
return actool.Errorf("kill_work 当前不可用"), nil
}
var a struct {
IntentID json.RawMessage `json:"intent_id"`
}
_ = json.Unmarshal(in, &a)
id := pid(a.IntentID)
if id <= 0 {
return actool.Errorf("intent_id 必填"), nil
}
node, err := t.ts.GetNode(id)
if err != nil || node == nil || node.Kind != db.KindIntent {
return actool.Errorf("intent_id 必须是本任务的意图(关联任务意图只读)"), nil
}
if err := t.killWork(id); err != nil {
return actool.Errorf(err.Error()), nil
}
return actool.Text(fmt.Sprintf("已向意图 %d 的 work 发送终止信号", id)), nil
})
}
// steerWorkTool lets the planner inject a mid-run course-correction into a running
// work WITHOUT killing it: the message reaches the worker before its next tool call,
// which re-plans its next step (already-gathered context is kept). For in-intent
// nudges ("停做 X、聚焦 Y"); if the whole direction is wrong use kill_work + a new intent.
func (t *ToolSet) steerWorkTool() actool.CoreTool {
return t.writeExpTool("steer_work", "给一条正在运行的意图(work)实时注入纠偏指令,不打断它、不丢已有进展:worker 会在下一步动作前收到你的指令并据此调整。用于'别再走 X、聚焦 Y'这类【意图内】纠偏;若方向整个错了应改用 kill_work 再下新意图。建议先用 get_worker_output 看它在干嘛。",
obj(map[string]any{
"intent_id": idp("要纠偏的意图 id(= work 句柄)"),
"message": str("给 worker 的纠偏指令,明确让它停止什么、转向什么"),
}, "intent_id", "message"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
if t.steerWork == nil {
return actool.Errorf("steer_work 当前不可用"), nil
}
var a struct {
IntentID json.RawMessage `json:"intent_id"`
Message string `json:"message"`
}
_ = json.Unmarshal(in, &a)
id := pid(a.IntentID)
if id <= 0 {
return actool.Errorf("intent_id 必填"), nil
}
node, err := t.ts.GetNode(id)
if err != nil || node == nil || node.Kind != db.KindIntent {
return actool.Errorf("intent_id 必须是本任务的意图(关联任务意图只读)"), nil
}
if strings.TrimSpace(a.Message) == "" {
return actool.Errorf("message 必填"), nil
}
if err := t.steerWork(id, a.Message); err != nil {
return actool.Errorf(err.Error()), nil
}
return actool.Text(fmt.Sprintf("已向意图 %d 的 work 注入纠偏指令(下一步生效)", id)), nil
})
}
// getWorkerOutput returns a work's final (or截至中止时的) conclusion text by intent id.
func (t *ToolSet) getWorkerOutput() actool.CoreTool {
return t.readExpTool("get_worker_output", "取本任务或直接关联任务某条意图(work)的最终输出结论。关联任务结果带 source_task_id/inherited=true 且只读。正常结束返回其总结;被终止(stopped)/异常的 work 返回其截至中止时的最后输出。",
obj(map[string]any{"intent_id": idp("意图 id(= work 句柄)")}, "intent_id"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
IntentID json.RawMessage `json:"intent_id"`
}
_ = json.Unmarshal(in, &a)
id := pid(a.IntentID)
if id <= 0 {
return actool.Errorf("intent_id 必填"), nil
}
intentNode, err := t.ts.GetNodeWithSources(id)
if err != nil {
return actool.Errorf(err.Error()), nil
}
if intentNode == nil || intentNode.Kind != db.KindIntent {
return actool.Errorf("intent_id 不属于本任务或其直接关联任务"), nil
}
acts, _, err := t.ts.ActivityListWithSources(id, 0, 1000)
if err != nil {
return actool.Errorf(err.Error()), nil
}
var chosen, fallback *db.Activity
for i := range acts {
switch acts[i].Kind {
case "result":
chosen = &acts[i]
fallback = &acts[i]
case "text":
fallback = &acts[i]
}
}
pick := chosen
if pick == nil {
pick = fallback
}
if pick == nil {
if intentNode.Inherited {
return jsonResult(inheritedMap(map[string]any{
"intent_id": id, "final_text": "(该 work 尚无任何输出)",
}, intentNode.SourceTaskID))
}
return actool.Text("(该 work 尚无任何输出)"), nil
}
detail, _ := t.ts.ActivityDetailWithSources(pick.ID)
if detail == "" {
detail = pick.Summary
}
result := map[string]any{
"intent_id": id, "final_text": detail,
"summary": pick.Summary, "is_error": pick.IsError,
}
if intentNode.Inherited {
inheritedMap(result, intentNode.SourceTaskID)
}
return jsonResult(result)
})
}
// traceSteps renders summary-only trace rows, re-truncating each summary to 100
// chars — the stored summary is capped at 200 for the UI transcript; the trace
// tools want it tighter since a whole work's step list is many rows.
func traceSteps(acts []db.Activity) []map[string]any {
steps := make([]map[string]any, 0, len(acts))
for i := range acts {
step := map[string]any{
"step_id": acts[i].ID, "kind": acts[i].Kind, "tool": acts[i].Tool,
"is_error": acts[i].IsError, "summary": firstLine(acts[i].Summary, 100),
}
if acts[i].Inherited {
inheritedMap(step, acts[i].SourceTaskID)
}
steps = append(steps, step)
}
return steps
}
// getWorkerTrace exposes a work's execution PROCESS (not just its final output):
// list step summaries, keyword-search within one work, or pull full detail of a
// few specific steps. Thinking steps are excluded everywhere.
func (t *ToolSet) getWorkerTrace() actool.CoreTool {
return t.readExpTool("get_worker_trace",
"查看某条意图(work)的【执行过程】(区别于 get_worker_output 只给最终结论)。三种用法:\n"+
"① 只传 intent_id → 返回该 work 每一步的摘要流(summary≤100字,含 step_id;只是动作轮廓,不含完整输出);\n"+
"② intent_id + q → 只返回命中关键字的步骤摘要(在摘要和完整输出里都搜;仍只给 summary,要看内容用③);\n"+
"③ intent_id + step_ids → 返回这些步骤的完整内容(detail);一次最多取 5 个,超出只返回前 5 个并在 notice/omitted_step_ids 里告知未取的。\n"+
"典型流程:先①/②定位可疑步骤的 step_id,再用③取其完整输出。不含思考(thinking)步骤。支持直接关联任务的历史 trace;其结果带 source_task_id/inherited=true 且只读。",
obj(map[string]any{
"intent_id": idp("意图 id(= work 句柄)"),
"q": str("关键字:只返回摘要/完整输出命中它的步骤(可选;与 step_ids 互斥)"),
"step_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "要取完整内容的 step_id(来自①/②返回;一次最多取 5 个,多传只返回前 5 个,其余在 omitted_step_ids 里列出)"},
"limit": intp("摘要流/检索的返回上限(可选)"),
}, "intent_id"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
IntentID json.RawMessage `json:"intent_id"`
Q string `json:"q"`
StepIDs []json.RawMessage `json:"step_ids"`
Limit int `json:"limit"`
}
_ = json.Unmarshal(in, &a)
id := pid(a.IntentID)
if id <= 0 {
return actool.Errorf("intent_id 必填"), nil
}
intentNode, nodeErr := t.ts.GetNodeWithSources(id)
if nodeErr != nil {
return actool.Errorf(nodeErr.Error()), nil
}
if intentNode == nil || intentNode.Kind != db.KindIntent {
return actool.Errorf("intent_id 不属于本任务或其直接关联任务"), nil
}
// ③ detail drill-down by step ids, thinking excluded by the store.
if len(a.StepIDs) > 0 {
// Dedup + drop invalid ids first so garbage/duplicates don't eat into
// the per-call cap. detail is returned in full (untruncated), so the
// cap bounds one tool result; over the cap we serve the first N and
// tell the model exactly which ids were deferred, instead of erroring
// and forcing it to re-plan the call.
const maxStepIDs = 5
var ids []int64
seen := make(map[int64]bool)
for _, raw := range a.StepIDs {
if v := pid(raw); v > 0 && !seen[v] {
seen[v] = true
ids = append(ids, v)
}
}
var omitted []int64
if len(ids) > maxStepIDs {
omitted = append(omitted, ids[maxStepIDs:]...)
ids = ids[:maxStepIDs]
}
acts, err := t.ts.ActivityByIDsWithSources(ids)
if err != nil {
return actool.Errorf(err.Error()), nil
}
steps := make([]map[string]any, 0, len(acts))
for i := range acts {
if acts[i].NodeID == nil || *acts[i].NodeID != id || acts[i].Inherited != intentNode.Inherited ||
(acts[i].Inherited && acts[i].SourceTaskID != intentNode.SourceTaskID) {
continue
}
step := map[string]any{
"step_id": acts[i].ID, "kind": acts[i].Kind, "tool": acts[i].Tool,
"is_error": acts[i].IsError, "detail": acts[i].Detail,
}
if acts[i].Inherited {
inheritedMap(step, acts[i].SourceTaskID)
}
steps = append(steps, step)
}
result := map[string]any{"intent_id": id, "steps": steps, "returned_step_ids": ids}
if len(omitted) > 0 {
// returned_step_ids/omitted_step_ids let the model decide programmatically
// whether another call is worth it; the notice states the same in prose.
result["omitted_step_ids"] = omitted
result["notice"] = fmt.Sprintf(
"每次最多取 %d 个步骤的完整内容,本次已返回前 %d 个(%v),未取的 %d 个为 %v。"+
"若这些内容已足够定位,则无需再取剩余步骤;确需继续时,用这些 step_id 再调一次。",
maxStepIDs, len(ids), ids, len(omitted), omitted)
}
if intentNode.Inherited {
inheritedMap(result, intentNode.SourceTaskID)
}
return jsonResult(result)
}
// ①/② summary stream, optionally keyword-filtered; 100-char summaries.
var acts []db.Activity
var err error
if strings.TrimSpace(a.Q) != "" {
acts, err = t.ts.ActivityTraceSearchWithSources(id, a.Q, a.Limit)
} else {
acts, err = t.ts.ActivityTraceWithSources(id, a.Limit)
}
if err != nil {
return actool.Errorf(err.Error()), nil
}
result := map[string]any{"intent_id": id, "steps": traceSteps(acts)}
if intentNode.Inherited {
inheritedMap(result, intentNode.SourceTaskID)
}
return jsonResult(result)
})
}
// searchAllWorkerTraces keyword-searches EVERY work's process in this task — for
// finding what a worker saw but never wrote back as a fact. Returns only matching
// summaries (≤100 chars), each tagged with its intent_id for follow-up drill-down.
func (t *ToolSet) searchAllWorkerTraces() actool.CoreTool {
return t.readExpTool("search_all_worker_traces",
"【通常不推荐使用,因为系统中已经给了大部分信息了】在【本任务其他 work 的执行过程】里按关键字(q)检索——用于找回某个 worker 见过、却没写进 fact 的东西(某路径/token/报错等)。"+
"已自动排除你自己这条意图的步骤(那些本就在你上下文里)。"+
"只返回命中步骤的摘要(summary≤100字),每条带 intent_id;据此再用 get_worker_trace(intent_id, step_ids=[...]) 取完整内容。",
obj(map[string]any{
"q": str("关键字(在所有 work 步骤的摘要+完整输出里搜)"),
"limit": intp("返回上限,默认 100(可选)"),
}, "q"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
Q string `json:"q"`
Limit int `json:"limit"`
}
_ = json.Unmarshal(in, &a)
if strings.TrimSpace(a.Q) == "" {
return actool.Errorf("q 必填"), nil
}
// 排除调用者自身这条意图的步骤(worker 的自有 trace 已在其上下文里)。
acts, err := t.ts.ActivityTraceSearchAllWithSources(t.ownerNode, a.Q, a.Limit)
if err != nil {
return actool.Errorf(err.Error()), nil
}
hits := make([]map[string]any, 0, len(acts))
for i := range acts {
var intent int64
if acts[i].NodeID != nil {
intent = *acts[i].NodeID
}
hit := map[string]any{
"intent_id": intent, "step_id": acts[i].ID, "worker": acts[i].Worker,
"kind": acts[i].Kind, "tool": acts[i].Tool, "is_error": acts[i].IsError,
"summary": firstLine(acts[i].Summary, 100),
}
if acts[i].Inherited {
inheritedMap(hit, acts[i].SourceTaskID)
}
hits = append(hits, hit)
}
return jsonResult(map[string]any{"query": a.Q, "hits": hits})
})
}
// listWorkerTraces gives a worker (which has no graph_overview and can't see the
// intent graph) a lightweight index of the works in this task — intent_id +
// one-line summary + state — so it can DISCOVER which works to inspect via
// get_worker_trace. Without this a worker only knows intent_ids that come back
// from search_all_worker_traces hits. Excludes still-open intents (not yet run →
// no process to inspect).
func (t *ToolSet) listWorkerTraces() actool.CoreTool {
return t.readExpTool("list_worker_traces",
"【通常不推荐使用,因为系统中已经给了大部分信息了】列出本任务里【已跑过的 work(意图)】索引:intent_id + 一句话方向(summary) + 状态。"+
"你(worker)看不到探索图,用它来发现有哪些 work 值得翻看——再用 get_worker_trace(intent_id) 看其步骤、get_worker_trace(intent_id, step_ids=[...]) 取详情。"+
"只列已执行的(running/done/exhausted/blocked/stopped),不含还没跑的 open。注意:你的任务边界仍是你领到的那条意图,看别的 work 只为复用观察/避免重复劳动。",
obj(map[string]any{
"q": str("按 summary 关键字过滤(可选)"),
"limit": intp("返回上限,默认 50(可选)"),
}),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
Q string `json:"q"`
Limit int `json:"limit"`
}
_ = json.Unmarshal(in, &a)
limit := a.Limit
if limit <= 0 {
limit = 50
}
all, err := t.ts.ListByKindWithSources(db.KindIntent, 500)
if err != nil {
return actool.Errorf(err.Error()), nil
}
q := strings.ToLower(strings.TrimSpace(a.Q))
out := make([]map[string]any, 0, limit)
for _, n := range all {
if n.Inherited && n.State == "running" {
continue
}
switch n.State {
case "running", "done", "exhausted", "blocked", "stopped": // has run → has a process
default:
continue
}
var p map[string]any
_ = json.Unmarshal(n.Payload, &p)
summary, _ := p["summary"].(string)
if q != "" && !strings.Contains(strings.ToLower(summary), q) {
continue
}
item := map[string]any{"intent_id": n.ID, "summary": summary, "state": n.State}
if n.Inherited {
inheritedMap(item, n.SourceTaskID)
}
out = append(out, item)
if len(out) >= limit {
break
}
}
return jsonResult(map[string]any{"works": out})
})
}
// PlannerTools is the read + intent-generation + goal-judgement tool set.
func (t *ToolSet) PlannerTools() []actool.CoreTool {
return []actool.CoreTool{
t.graphOverview(), t.listFindings(), t.listFacts(), t.nodeDetail(),
// cold-digest §6.1: restore folded cold nodes (digest body → members → detail).
t.expandDigest(),
t.getWorkerOutput(), t.getWorkerTrace(), t.searchAllWorkerTraces(), t.listGoals(), t.addIntent(), t.proveGoal(), t.goalMet(),
t.killWorkTool(), t.steerWorkTool(),
// report_finding:规划态势研判时若自身已确证漏洞,可直接登记(与 worker 同工具)。
t.addFinding(),
// list_companies:查看企业列表 + scope + 资产数(拿 company_id / 理解归属范围)。
t.listCompanies(),
// list_assets:规划时按 DSL 检索全资产库(配合 list_untested_assets 的"范围内未测"视角,
// 补上"按域名/指纹/端口/状态码等条件在整库里查"的能力)。
t.listAssets(),
// add_company_scope:规划时可把域名/IP/CIDR/ICP/关键词纳入某公司的资产范围(自动认领命中资产)。
t.addCompanyScope(),
// add_task_scope:主动把整根域/整公司/某子域/IP 纳入本任务测试范围(覆盖度分母)。
t.addTaskScope(),
// list_untested_assets:按需查本任务范围内未测资产(类型+分页),自行决定补测。
t.listUntestedAssets(),
}
}