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

474 lines
33 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"
"strings"
"sync"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/llm"
"github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool"
"github.com/Autumn-27/norma/transcript"
)
// Planner is the event-driven LLM planner (docs §4.3): each time the asset or
// exploration graph changes (debounced), it reads the exploration route, queries
// assets, judges whether the task goal is met, and emits 0..N exploration intents
// into the frontier. It is the sole intent generator.
type Planner struct {
findingRecorder FindingRecorder
prov llm.Provider
model string
tx *transcript.Store // raw LLM conversation persistence (nil = off)
window int // context window in tokens (for compaction)
windowFn func() int // optional dynamic task-chain minimum
maxTurns int // max agent turns per run (0 = unlimited)
killWork func(intentID int64) error // engine callback to terminate a running work (nil = off)
steerWork func(intentID int64, msg string) error // engine callback to steer a running work mid-run (nil = off)
proxyAddr string // recording proxy for WebFetch (empty = direct)
proxyCACert string // recording proxy's CA cert path (HTTPS verify)
webSearch WebSearchOpts // web_search tool backend selection (off by default)
workDir string // shared work dir (surfaced in prompt as artifact-output target)
injectConstraints func() bool // resolver: inject task operation constraints into system prompt? (nil = yes)
nonStreamingFn func() bool // resolver: use non-streaming (Complete) path? (nil = streaming)
noaEnabledFn func() bool // resolver: use experimental noa compaction? (nil = off)
maxTokensFn func() int // resolver: per-reply output cap (nil/0 = send no cap)
compactor *Compactor // cold-node compaction (§7); nil = disabled
// todos keeps ONE plan-scratchpad per task (keyed by exploration id) so the
// planner's multi-step plan survives across wake-ups — each Plan() is a fresh
// session, but the shared store lets it record a serial exploit chain once and
// dispatch it step-by-step over rounds instead of front-loading it in parallel.
todoMu sync.Mutex
todos map[int64]*actool.TodoStore
}
func NewPlanner(prov llm.Provider, model, workDir string, tx *transcript.Store, window, maxTurns int) *Planner {
return &Planner{prov: prov, model: model, workDir: workDir, tx: tx, window: window, maxTurns: maxTurns, todos: map[int64]*actool.TodoStore{}}
}
func (p *Planner) SetCompactionWindowResolver(fn func() int) { p.windowFn = fn }
// SetCompactor wires the cold-node compactor (cold-digest §7). Called each
// planner wake-up to advance the round counter, maintain cold stamps, and
// (off the hot path) fold cold nodes into digests. nil = feature disabled.
func (p *Planner) SetCompactor(c *Compactor) { p.compactor = c }
// SetNonStreaming wires a resolver deciding whether runs use the non-streaming
// model path (true = non-streaming). nil/unset = streaming (default).
func (p *Planner) SetNonStreaming(fn func() bool) { p.nonStreamingFn = fn }
func (p *Planner) nonStreaming() bool { return p.nonStreamingFn != nil && p.nonStreamingFn() }
// SetNoaEnabled wires a resolver deciding whether runs use the experimental noa
// context-compression mechanism. nil/unset = off (built-in compaction). Read per
// run so the settings toggle takes effect without rebuilding the agent.
func (p *Planner) SetNoaEnabled(fn func() bool) { p.noaEnabledFn = fn }
// SetMaxTokens wires a resolver for the per-reply output cap. nil/unset or 0 =
// send no cap and let the endpoint decide. Read per run, like nonStreaming.
func (p *Planner) SetMaxTokens(fn func() int) { p.maxTokensFn = fn }
func (p *Planner) maxTokens() int {
if p.maxTokensFn == nil {
return 0
}
return p.maxTokensFn()
}
func (p *Planner) compactionWindow() int {
if p.windowFn != nil {
return p.windowFn()
}
return p.window
}
// SetProxy points the planner's WebFetch at the recording proxy plus the CA cert
// it trusts to verify HTTPS through it (empty addr = direct).
func (p *Planner) SetProxy(addr, caCert string) { p.proxyAddr, p.proxyCACert = addr, caCert }
// SetWebSearch selects the web_search backend for the planner (off by default).
func (p *Planner) SetWebSearch(o WebSearchOpts) { p.webSearch = o }
// SetConstraintInject wires a resolver deciding whether this task's operation
// constraints get injected into the planner system prompt. Read per round so the
// settings toggle takes effect without rebuilding the agent. nil = inject (default).
func (p *Planner) SetConstraintInject(fn func() bool) { p.injectConstraints = fn }
// wantConstraints reports whether constraint injection is enabled (default yes).
func (p *Planner) wantConstraints() bool { return p.injectConstraints == nil || p.injectConstraints() }
// todoFor returns the task's persistent planning todo store, creating it on first
// use. Shared across all of this task's planner wake-ups.
func (p *Planner) todoFor(expID int64) *actool.TodoStore {
p.todoMu.Lock()
defer p.todoMu.Unlock()
s := p.todos[expID]
if s == nil {
s = actool.NewTodoStore()
p.todos[expID] = s
}
return s
}
// SetKillWork wires the engine's per-work terminate callback so the planner's
// kill_work tool can stop a single running worker.
func (p *Planner) SetKillWork(fn func(intentID int64) error) { p.killWork = fn }
// SetSteerWork wires the engine's per-work steering callback so the planner's
// steer_work tool can inject a mid-run course-correction into a running worker.
func (p *Planner) SetSteerWork(fn func(intentID int64, msg string) error) { p.steerWork = fn }
// renderPlannerTodos formats the persistent planning todo for injection into the
// wake-up prompt (empty when there are no todos yet — first wake-up).
func renderPlannerTodos(items []actool.Todo) string {
if len(items) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n\n【你的规划待办(跨唤醒保留,上一轮你写的)】:\n")
for _, it := range items {
mark := map[actool.TodoStatus]string{actool.TodoPending: "☐", actool.TodoInProgress: "▶", actool.TodoCompleted: "✔"}[it.Status]
if mark == "" {
mark = "☐"
}
b.WriteString(fmt.Sprintf(" %s %s\n", mark, it.Content))
}
b.WriteString("据此推进:只对【前置步骤已完成 / 其依赖的 fact 已存在】的下一步派意图;用 TodoWrite 更新清单(把已被 fact 满足的步骤标 completed)。不要重复派已在清单里 pending/in_progress 的步骤。")
return b.String()
}
// TriggerEvent describes what concretely caused this planning round to fire, so
// the planner looks first at the actual change instead of re-scanning the whole
// overview. Kind:
//
// "done" — a worker finished intent IntentID (its output conclusion is fetched).
// "finding" — a worker reported a finding on intent IntentID (Detail = 摘要).
// "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a
// single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量).
// "goal_deleted" — the human deleted a goal from 总览的目标管理 (Detail = 被删目标文本).
// "goal_edited" — the human edited a goal from 总览的目标管理 (OldGoal→NewGoal 文本).
// "cancelled" — the human deleted intent IntentID (Detail = 删除原因). The intent is
// stopped (not deleted) and the reason is attached to it as a fact.
type TriggerEvent struct {
Kind string
IntentID int64
Detail string
Summary string // Kind=="cancelled" 专用:删除前捕获的意图摘要(真删除后节点已不存在,无法再查)
Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条)
OldGoal string // Kind=="goal_edited" 专用:修改前的目标文本
NewGoal string // Kind=="goal_edited" 专用:修改后的目标文本
Hints []string // Kind=="hint" 专用:本次 add_hint 新增的提示文本(1 条或多条)
}
// renderTriggers spells out the change(s) that fired this round: for a finished
// worker — which intent + its output conclusion; for a finding — which intent +
// what was found. Empty for time/heartbeat wakes. Reads the store (best-effort;
// a blank field never blocks the round).
func renderTriggers(ts *db.ExplorationStore, evs []TriggerEvent) string {
if len(evs) == 0 || ts == nil {
return ""
}
var b strings.Builder
b.WriteString("\n\n【本次触发本轮的实际变动(先看这里,再决定是否补方向)】:")
for _, ev := range evs {
switch ev.Kind {
case "goal":
if len(ev.Goals) == 1 {
b.WriteString(fmt.Sprintf("\n- 人(主 agent)新增了一个目标:%s —— 新的待达成目标,请据此补充探索方向(若尚无对应意图)。", ev.Goals[0]))
} else {
b.WriteString(fmt.Sprintf("\n- 人(主 agent)新增了 %d 个目标:%s —— 均为新的待达成目标,请逐一为尚无对应意图的目标补充探索方向。", len(ev.Goals), strings.Join(ev.Goals, ";")))
}
case "hint":
if len(ev.Hints) == 1 {
b.WriteString(fmt.Sprintf("\n- 人(主 agent)新增了一条战略提示:%s —— 已挂到探索图上,请据此调整/补充探索方向(若尚无对应意图)。", ev.Hints[0]))
} else {
b.WriteString(fmt.Sprintf("\n- 人(主 agent)新增了 %d 条战略提示:%s —— 均已挂到探索图上,请逐一据此调整/补充探索方向。", len(ev.Hints), strings.Join(ev.Hints, ";")))
}
case "goal_deleted":
b.WriteString(fmt.Sprintf("\n- 人删除了该目标:%s —— 该目标已移除,请据此重判剩余目标/方向(不必再为它派意图)。", ev.Detail))
case "goal_edited":
b.WriteString(fmt.Sprintf("\n- 人修改了目标,由「%s」变为「%s」—— 请据新目标调整探索方向(原方向若已不适用请停派)。", ev.OldGoal, ev.NewGoal))
case "finding":
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 报告了一个 finding:%s", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail))
case "cancelled":
// 意图内容优先用删除时捕获的 Summary(真删除后节点已不存在,intentSummary 查不到)。
sm := ev.Summary
if sm == "" {
sm = intentSummary(ts, ev.IntentID)
}
b.WriteString(fmt.Sprintf("\n- 意图 #%d 由用户删除,意图内容是:%s、删除原因是:%s。该意图已删除(不再执行);请据此重新规划。", ev.IntentID, sm, ev.Detail))
default: // "done"
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 结束,输出结论:%s", ev.IntentID, intentSummary(ts, ev.IntentID), workerOutput(ts, ev.IntentID)))
if fids := factIDsYielded(ts, ev.IntentID); fids != "" {
b.WriteString(fmt.Sprintf(";本意图新产生的事实 id:%s ", fids))
}
}
}
b.WriteString("\n(完整细节可 node_detail / get_worker_output / list_findings 再查。)")
return b.String()
}
// factIDsYielded lists the fact ids an intent produced this run as "#12、#15", so the
// planner can jump straight to the round's incremental facts. Empty (best-effort) when
// the intent yielded no facts or the lookup fails.
func factIDsYielded(ts *db.ExplorationStore, id int64) string {
ids, err := ts.FactsYielded(id)
if err != nil || len(ids) == 0 {
return ""
}
parts := make([]string, len(ids))
for i, fid := range ids {
parts[i] = fmt.Sprintf("#%d", fid)
}
return strings.Join(parts, "、")
}
// intentSummary reads an intent node's one-line summary (best-effort, "?" on miss).
func intentSummary(ts *db.ExplorationStore, id int64) string {
n, err := ts.GetNode(id)
if err != nil || n == nil {
return "?"
}
var p map[string]any
if json.Unmarshal(n.Payload, &p) == nil {
if s, ok := p["summary"].(string); ok && s != "" {
return s
}
}
return "?"
}
// workerOutput returns the finished worker's conclusion for an intent — the last
// 'result' (else 'text') activity's full detail, truncated. Same source get_worker_output uses.
func workerOutput(ts *db.ExplorationStore, id int64) string {
acts, _, err := ts.ActivityList(&id, 0, 1000)
if err != nil {
return "(取输出失败)"
}
var pick *db.Activity
for i := range acts {
if acts[i].Kind == "result" {
pick = &acts[i]
} else if acts[i].Kind == "text" && pick == nil {
pick = &acts[i]
}
}
if pick == nil {
return "(该 work 尚无输出记录)"
}
out, _ := ts.ActivityDetail(pick.ID)
if out == "" {
out = pick.Summary
}
return truncOutput(out, 800)
}
// truncOutput caps a worker-output blob so the trigger context doesn't bloat the
// system prompt every round; full text is one get_worker_output call away.
func truncOutput(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + " …(已截断,完整见 get_worker_output)"
}
// renderGraphOverview folds the pre-computed graph_overview snapshot into the
// wake-up prompt so the planner starts each round with the full situation in
// hand — saving the round-trip it would otherwise spend calling the tool. It is
// the exact same JSON graph_overview would return; deeper detail is still one
// tool call away (node_detail / list_facts / …).
func renderGraphOverview(data map[string]any) string {
b, err := json.Marshal(data)
if err != nil {
return "" // fall back to the model calling graph_overview itself
}
return "\n\n【本轮态势(graph_overview 预取,等同你调用该工具的返回;需要细节再按需调 node_detail/list_facts 等)】:\n" + string(b)
}
// plannerDefaultTmpl is the built-in EDITABLE body (段 [A]) of the planner prompt,
// seeded into agent_prompts. Goal is a {{.Goal}} template var; the 中间产物输出规约
// tail is code-owned (artifactSpec) and appended by plannerSystem after rendering.
const plannerDefaultTmpl = `你是一个网络安全平台授权渗透测试系统的"规划者",被频繁唤醒(图一变就唤醒)。职责:读态势 → 判目标 → **只在确有未被覆盖的新方向时**补充探索意图。你是规划者、不是执行者:本轮所有产物只能是【生成/说清意图】或【判定目标】,绝不在 plan 里把活干了。
任务目标:{{.Goal}}
**本轮该产出几个意图(先想清楚这条)**:
- **硬底线(最高优先)**:只要【目标未达成】且【当前没有任何 open 或 running 意图】(frontier_open=0 且 running_intents 为空),本轮就【必须】产出至少一个向目标推进的意图——没有在跑的 work 可等、也没有在排队的方向时,产出 0 意图=任务停摆;哪怕已知方向都只在 recent_done 里,也要据下面 done/exhausted/blocked 的判断另开一条或续派一条。
- 硬底线之外,**产出 0 个意图是正常结果,但要有正当理由**(不是"少派更稳"的默认):①**已覆盖**——你想到的方向都已被仍在 open/running 的意图处理(换措辞重复生成已存在的意图是严重错误);②**等待依赖**——下一步依赖当前在跑 work 的产出、而它还没出来(此时硬派会让下游拿不到前置而空转,应等下次唤醒图更新后再派)。
- 反过来:确有【未覆盖、且不依赖在跑 work】的新方向,或目标未达成且范围内仍有未测面,就该派——别把 0 意图当偷懒的默认。
**每次唤醒的决策流程**:
1. **完整态势已附在本提示下方**(就是 graph_overview 的返回,无需再调它):task(原始标题+目标/根节点)、资产计数、goals+状态、open/running/recent_done 意图、sites_without_endpoints(无端点的站点,提示可能待探的方向)、facts(探索事实数,与漏洞是两类)、recent_facts({id,summary,confidence?})。
- **范围**:探索节点(goals/意图/facts/findings)只含本任务;**资产图全局共享**(多任务同一份,资产计数是全局在范围内的、非本任务独有)——出现非本任务相关的资产时忽略。
- **血缘**:每个意图带 parents(上游:派生自哪些事实/意图)和 yields(下游:产生了哪些事实/发现),recent_facts 每条带 from_intent;据此理解"哪些事实来自哪个方向、能否综合出新方向"。
- **否定/存疑观察**(recent_facts 里"端口关闭/不可注入"等)是 worker 的观察、不是定论:采信前先 node_detail(id) 看 evidence——evidence 扎实、confidence=observed 且手段已穷尽的才视为该方向暂时封住;evidence 缺失、只是"看起来像/只探一次"、或 confidence=inferred 的,按【尚未探明】处理,若在范围内且无其它意图覆盖,默认派一条复核意图去证实或推翻(**同一否定方向至多复核一次**;复核后仍为否定、且证据合理,就尊重该结论、不再派)。
- **要更深细节才按需调**:list_facts(分页,最新在前,默认 20,可 q 过滤、before 翻页,带 total/has_more)、list_findings(全部漏洞)、node_detail(id)(完整证据/详情;列表/recent_facts 只给摘要)、list_assets(pull:q 搜索、type/company_id/task_id 过滤、分页,或 id/ids 直取)、asset_neighbors。资产全局共享,别默认拉全量。
2. **判目标(核心职责)**:goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id, evidence_id, reason) 标 met。**当你标记的恰是最后一个未完成目标时,系统自动判定整个任务完成**——收官只由逐个 prove_goal 驱动,没有别的"一键完成"手段。
- ⚠️ **量化验收核对(严禁提前盖章)**:目标含可量化条件(覆盖度达 X%、拿 N 个 flag、获得某权限)时,prove_goal 前【必须】核对上方 graph_overview 的实测值(coverage.pct、findings_total 计数等):未达标就【禁止】prove_goal,改派意图补差;不得以"大体达成/核心已拿下"为由提前标 met。例:要求覆盖度 100% 而实测 coverage.pct=40% → 未达成,继续派补测意图。
3. **(可选,仅开局、极轻量)探测理解**:仅当图里几乎还没有 fact(recent_facts 基本为空、任务刚开始)、仅凭态势无法把初始意图说具体时,才用 Bash 等对目标做极少量、只读的探测(如 1–2 次 curl 看首页/指纹)。**唯一合法产物是一句更精准的意图描述**——绝不是漏洞的发现/验证/利用,也不是端点/目录/参数的枚举结果(那些是 worker 的活,写成意图派下去)。三条硬边界:
- 图里已有 worker 产出的 fact(facts>0 / recent_facts 非空)→【禁止】再自己探测,一切判断基于已有 fact,本轮产物只能是"派新意图"或"结束";想深挖某线索 → 派意图让 worker 去查,不是自己 curl。
- 即使开局也最多探 ≤3 次就收手,只为把初始意图说清;一旦发现自己在"深入查证"而非"快速定方向"(逐个枚举端点/目录、逐个试 id、解码链、反复探同一接口、任何注入/越权/漏洞的测试验证——全是 worker 的重活),立刻停手写成意图。
- 能从现有事实/态势判断的,根本不必探测。
4. **决定补哪些新方向**:**这里的"克制"只指【不重复已存在的意图】,不是"能少派就少派"**——目标未达成时,默认追问是"为逼近目标,还有哪些更深、更狠、尚未覆盖的打法",而不是"是否可以收尾"。意图是【开放的探索方向】(不是固定类型/菜单),结合已知事实、资产、目标自判方向,逐一与 open + running + recent_done 比对:
- 已有 open/running 覆盖 → 不再生成(正在处理)。
- 在 recent_done 里出现过 → **先看该意图的 state(每条都带)分辨怎么停的,再决定**:
· **done(正常跑完)**:已覆盖 → 不原样重派;是否死路看它 yields 出的 fact 结论、而非 state;仅出现【材料性新机理】(新事实/资产/参数/明显不同的打法)才重派,且 summary 写清与上次的不同;换措辞、"再试一次说不定行"不算,禁止重试。
· **exhausted(预算耗尽、探到一半被掐断,只写回部分)/ blocked(模型或网络失败、基本没探成)**:都是中途没善终、信息不全——先用 get_worker_trace / get_worker_output 看它实际做到哪、卡在哪,再从下列里选:接近突破被预算掐 → 派"接上次进度继续";纯外部故障没跑成(blocked 常是)→ 直接重派同方向;每次卡同一处 → 换打法/方向。依据永远是 trace 里的真实进度,不是 state 本身。
- 完全无任何意图覆盖的全新方向 → 生成。
- 所有已知方向都被仍在 open/running 的意图覆盖 → 不生成、直接结束(有在跑/在排队的 work,等它们推进);但若只剩 recent_done 覆盖、已无 open/running 而目标未达成 → 按顶部硬底线必须另开或续派。
- **深度优先于覆盖度**:coverage 是下限/验收项、不是探索目标本身;发现高价值入口(可能通向 RCE/提权/数据外泄)后,优先派意图把那条路【往深打穿】,而不是为拉平覆盖度去铺广、逐个资产浅测。
- **保持路线多样、别过早收敛**:目标未达成时,若现有意图都挤在同一条路线/入口,而存在【本质不同】的未覆盖方向(另一入口面/另一类资产/另一条利用链),优先补那条分歧方向,而不是在同一线上加同义意图(看实质差异,不看措辞);若该分歧方向已被现有意图覆盖,仍不生成。理想是 2–3 条机理不同的路线并存(如"从上传链打"与"从认证绕过打"),某条交出【目标逼近】的证据后才把资源集中过去。**但多样性永远服从顶部【操作约束】**:被约束排除的入口面/端口/主机/操作,即使本质不同也绝不生成意图。
**串行利用链:分步派,别拆成并行。** 强依赖串行链(①→②→③,后一步依赖前一步的实际产出):不要一次性并行下发(下游拿不到还不存在的前置只会重复/空转);用 TodoWrite 把整条链记成待办(每步一条),本轮只派"前置已满足"的那步(通常第一步),待它产出 fact 后下次唤醒(提示会带上待办清单)再派下一步并把已满足的标 completed。"同一件事"别拆两条("确认触发点"和"触发触发点"是同一步);只有【平行、互不依赖】的维度(如枚举多个不相关端点)才用多意图并行。
5. **提交**:用【一次】add_intent 批量提交筛出的新方向(intents 数组,最多 4 个最高价值的,不要逐条多次调):
- **summary**:一句话自然语言描述该方向(测试目标完整地址 + 做什么 + 为什么),不套固定分类;去重主要靠它与已有意图比对。
- **asset_ids**:本方向要测试/攻击的目标资产 id(尽量传,0/1/多个,来自 list_assets)——只要方向围绕具体资产(站点/接口/参数/主机)就务必传,用于覆盖去重、连入资产链路,跨多资产就都传;纯全局侦察无具体资产才留空。
- **parent_ids**:本方向由哪些上游节点综合得出(可选,0/1/多个)——多个事实结合产生一个意图就都传,派生自某上游意图/发现也传其 id,顶层全新方向留空。
不重复、不硬凑;但目标未达成、又有未覆盖且更深的打法时,该派就派。简洁、聚焦、高效。`
func plannerSystem(goal, dataDir, workDir string) string {
body := renderSystem("planner", plannerDefaultTmpl, PlannerVars{Goal: goal, DataDir: dataDir, Now: nowStr()})
return body + artifactSpec(workDir) + langDirective()
}
// Plan runs one planning round. emit, if non-nil, receives the planner's execution
// steps (so users can see how it reads the situation and judges goals — the
// planner is the intent generator and was previously a black box). Returns whether
// the planner judged the goal met.
// triggers carries the concrete change(s) that fired this round — worker(s) done
// and/or finding(s) reported (may be several — the engine debounces a burst; empty
// for time/heartbeat wakes). They are spelled out at the top of the prompt so the
// planner looks first at the actual change (which intent, its output/finding).
func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, goal string, triggers []TriggerEvent, emit func(db.Activity)) (met bool, reason string, err error) {
// cold-digest §2.3/§7: advance this task's planner-round counter, maintain the
// cold_since_round stamps, and (if a threshold is hit) kick off background
// compaction. Synchronous part is cheap (a few queries); the LLM compaction
// runs in a detached goroutine so it never adds latency to this round.
p.compactor.OnPlannerRound(ctx, ts)
tsx := NewToolSet(ts, "planner")
tsx.SetFindingRecorder(p.findingRecorder)
if as != nil {
tsx.SetAssetStore(as, as.Companies())
}
tsx.SetTaskID(taskID)
tsx.SetCoverageEnabled(as == nil || as.CoverageEnabled(taskID))
tsx.killWork = p.killWork // enable kill_work tool (nil = unavailable)
tsx.steerWork = p.steerWork // enable steer_work tool (nil = unavailable)
if origin, _ := ts.OriginFactID(); origin > 0 {
tsx.SetOwnerNode(origin) // planner-side anchors default to the task root (origin fact)
}
// 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash)
// 资产覆盖度功能关闭时剔除 add_task_scope/list_untested_assets(不入 prompt)。
base := append(tsx.DropCoverageTools(tsx.PlannerTools()), actool.DefaultTools()...)
ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts)})
tools, def, cleanup := AugmentTools(ctx, "planner", base)
defer cleanup()
// 关键态势(刚完成的意图 + 预取的完整图)改放【本轮 user 输入】(见下方 input),system
// 只留静态规划正文。move-out 让 system 每轮稳定、更利于缓存;代价是若单轮变长,态势可能
// 被 compaction 压缩(planner 单轮通常短,风险低)。situational 会拼进下方 input。
situational := renderTriggers(ts, triggers) + renderGraphOverview(tsx.graphOverviewData())
// 任务级 deadline / 终局模式(经 ctx 注入,见 taskclock.go)。终局那一轮把任务超时
// planner 收尾词作为【本轮操作指令】拼进本轮 user 输入(随 situational),让它只做最后
// 目标判定、不产新意图。
tc := taskClockFrom(ctx)
if tc.Final {
situational += "\n\n【任务终局收尾(本轮特殊指令,覆盖上面的常规规划流程)】:" + resolveTaskTimeoutWrapup("planner")
}
// 本任务的工作目录 <workDir>/tasks/<taskID>,先建好。
taskDir := ensureRunDir(p.workDir, taskID, 0)
ctx = intercept.WithReviewContext(ctx, taskDir, intercept.ReviewBackground{})
sysBody := plannerSystem(goal, p.workDir, taskDir)
if p.wantConstraints() {
sysBody += constraintBlock(ts) // 操作约束(若有)注入系统提示,框定探索边界
}
system, boundary := deferredSystem(sysBody, def)
// planner 无自身墙钟预算;有 deadline 时把 MaxDuration 夹逼到剩余,让在跑的规划轮在
// 任务到点时进收尾(因超时→任务超时词,因步数→per-run 词)。
maxDur, clamped := clampMaxDuration(tc.DeadlineUnix, 0)
settle := wrapupSettlement("planner", nil)
if tc.DeadlineUnix > 0 {
settle = wrapupSettlementForTask("planner", nil, clamped)
}
opts := agentcore.Options{
Provider: p.prov,
SystemPrompt: system,
DynamicBoundary: boundary,
Tools: tools,
DeferredTools: def.Deferred,
UnlockSet: def.Unlock,
PermissionMode: permission.ModeBypass,
EnableWebFetch: true, // 走记录代理留痕;载入代理 CA 验证 MITM 重签的 HTTPS 证书
WebFetchProxy: p.proxyAddr,
WebFetchCACert: p.proxyCACert,
// 联网搜索(可选)。ddgs 无需 key;brave-free 需 BraveKey;tavily 需 TavilyKey。
// WebSearchProxy 是独立出口代理(http/https/socks5),与记录流量的 MITM 代理无关;空则直连。
EnableWebSearch: p.webSearch.Enabled,
WebSearchBackend: p.webSearch.Backend,
BraveSearchAPIKey: p.webSearch.BraveKey,
TavilySearchAPIKey: p.webSearch.TavilyKey,
DeepSeekSearchBaseURL: p.webSearch.DeepSeekBaseURL,
DeepSeekSearchAPIKey: p.webSearch.DeepSeekAPIKey,
DeepSeekSearchModel: p.webSearch.DeepSeekModel,
WebSearchProxy: p.webSearch.Proxy,
BashEnv: proxyEnv(p.proxyAddr, p.proxyCACert), // Bash 子命令默认走代理+信任 CA
WorkingDir: taskDir, // 本任务工作目录 <workDir>/tasks/<taskID>
ToolOutputDir: cmdOutDir(taskDir),
MaxTurns: p.maxTurns, // 0 = unlimited (configurable in agent management)
MaxDuration: maxDur, // 0=不限;有 deadline 时=距 deadline 剩余
Compaction: compactionConfig(p.compactionWindow()),
// 跨唤醒共享的规划待办:让串行链在多轮之间保留(session 是新的,store 不是)。
Todos: p.todoFor(ts.ID()),
// 命中【本轮】步数预算→ SDK 跑收尾:把本轮已想清楚的结论落地(该派的 add_intent、
// 能证的 prove_goal、串行链记 TodoWrite),而非停止规划——planner 之后仍会被反复唤醒。
// clamped(被任务 deadline 夹逼)时改用 PromptByReason(见 wrapupSettlementForTask)。
Settlement: settle,
NonStreaming: p.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete
MaxTokens: p.maxTokens(), // 0 = 不发上限,由服务端默认值决定
}
if p.tx != nil { // persist raw LLM conversation; one accumulating file per task's planner
opts.Transcript = p.tx
opts.SessionID = fmt.Sprintf("exp%d-planner", ts.ID())
}
// 实验功能:开启后由 noa 接管上下文压缩(归档集中在 <workDir>/noa/<SessionID> 下,持久)。
noaSession := fmt.Sprintf("exp%d-planner", ts.ID())
enableNoa(&opts, p.noaEnabledFn, p.workDir, noaSession, noaWarn(noaSession))
// 态势(刚完成的意图 + 完整图)现在拼进本轮 user 输入(见下方 input)。user 里还有
// 指令 + 跨唤醒待办(todo 是模型自己的规划便签,可再生,放 user 即可)。
// 开场白按「本轮有无具体变动」分两种:有变动 → 指向下方【实际变动】块;无变动
// (心跳定时巡检 / hint / 恢复等) → 别谎称"图发生了变化",转而提示顺带复查在跑意图。
lead := "刚有具体变动(见下面的【本次触发本轮的实际变动】),据此规划下一步:"
if len(triggers) == 0 {
lead = "本轮是**定时巡检(心跳到点)/无具体变动信号**的唤醒——图不一定有新变动。顺带复查在跑意图:长时间无进展或跑偏的用 steer_work 纠偏、方向整个错的用 kill_work 止损;再判定目标、决定是否补方向:"
// 心跳/无变动唤醒时,若全图已无任何 open 或 running 意图 → 探索已停摆(没 worker 在跑、
// 也没排队方向)。明确告知 planner 并强制其本轮补出新方向,别只复查在跑意图后空转一轮。
if active, err := ts.HasActiveIntent(); err == nil && !active {
lead = "本轮是**定时巡检(心跳到点)**的唤醒,且当前**已没有任何 open 或 running 的意图**——没有 worker 在跑、也没有排队中的方向,探索已停摆。你**必须**在本轮产出一个或多个向目标推进、且与图中既有意图**互不重复**的新意图(不得产出 0 意图);先据下面的态势判定目标是否已达成,未达成则立即补方向:"
}
}
input := lead + situational + "\n\n据上面的态势,判定目标。目标已【真正达成】(已拿到目标成果/已确认目标漏洞)时用 prove_goal 逐个标记。**硬底线:只要目标尚未达成、且当前没有任何 open 或 running 意图(frontier_open=0 且 running_intents 为空),本轮就必须产出至少一个向目标推进的意图——此时没有在跑的 work 可等、也没有在排队的方向,产出 0 意图=任务停摆。仅当已有 open/running 意图在推进、或目标已达成时,本轮才可以不产出新意图。**" +
renderPlannerTodos(opts.Todos.List())
// MaxDuration 现在会在墙钟到点打断在跑工具并就地进收尾(在活 ctx 上),单轮卡死不再
// 绕过收尾,无需外部硬 ctx 兜底。ctx 只承载 pause / kill / shutdown。
_, _, err = captureRun(ctx, opts, input,
func(r db.Activity) {
if emit != nil {
r.Worker = "planner" // planner activity has no intent_id (it generates them)
emit(r)
}
})
return tsx.GoalMet, tsx.Reason, err
}