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") } // 本任务的工作目录 /tasks/,先建好。 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, // 本任务工作目录 /tasks/ 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 接管上下文压缩(归档集中在 /noa/ 下,持久)。 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 }