package agent import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strconv" "strings" "time" "github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/intercept" "github.com/Autumn-27/norma/agentcore" "github.com/Autumn-27/norma/harness" "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" ) // Worker is an LLM work agent (docs §4.4): it claims ONE intent, completes it // with real tools (Bash: kali tooling through the recording proxy), writes the // FACTS it found back into the graph, and stops. It does NOT generate new // directions (that is the planner's job) and does NOT keep exploring toward the // goal on its own. Multiple workers run concurrently as goroutines. // WebSearchOpts is the web-search backend selection the server pushes into each // agent (planner/worker/main). Enabled=false leaves the web_search tool off. // Backend is "ddgs" (no key), "brave-free" (BraveKey required), "tavily" // (TavilyKey required), or "deepseek" (DeepSeek* required, filled from the // active LLM profile). It maps directly onto agentcore.Options. // Proxy is a dedicated egress proxy for the search request (http/https/socks5), // independent of the traffic-recording MITM proxy — set it when the search endpoint // is only reachable via a VPN/SOCKS proxy. Empty = direct. // // 注意 deepseek 后端与其它三个的性质不同:DeepSeek 没有可直接调用的搜索接口, // 搜索只存在于其 Anthropic 兼容 messages 接口内部(web_search_20250305 server // tool),因此每次搜索会消耗一次模型调用,且搜索请求由 DeepSeek 服务端发出—— // 不经过本机 Proxy,也不会进流量留痕。 type WebSearchOpts struct { Enabled bool Backend string BraveKey string TavilyKey string Proxy string // DeepSeek* 来自当前激活的 LLM 配置(仅 anthropic 格式的 DeepSeek 官方端点), // 不单独配置,随 LLM 配置切换而变。 DeepSeekBaseURL string DeepSeekAPIKey string DeepSeekModel string } type Worker struct { findingRecorder FindingRecorder prov llm.Provider model string workDir string proxyAddr string proxyCACert string // recording proxy's CA cert path (for WebFetch HTTPS verify) webSearch WebSearchOpts // web_search tool backend selection (off by default) 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) // runTimeout is the wall-clock budget for the main exploration of one intent // (0 = unlimited). When it fires, the run is cut and a settlement round is // forced so already-identified facts get written back instead of being lost. runTimeout time.Duration // extraTools are host-provided tools (e.g. traffic query, oast) appended to // the worker's graph write-back tools. extraTools []actool.CoreTool // injectConstraints resolves whether this task's operation constraints get // injected into the worker system prompt. Read per run so the settings toggle // takes effect without rebuilding the agent. nil = inject (default). injectConstraints func() bool // nonStreamingFn resolves whether this run uses the non-streaming (Complete) // path. Read per run so a profile/task toggle takes effect without rebuilding // the agent. nil = streaming (default). nonStreamingFn func() bool // noaEnabledFn resolves whether this run uses the experimental noa context- // compression mechanism. Read per run, like nonStreaming. nil = off (built-in // compaction). noaEnabledFn func() bool // maxTokensFn resolves the per-reply output cap in tokens, on the same // per-run basis. nil or 0 = send no cap and let the endpoint decide. maxTokensFn func() int } // WorkerSessionID returns the stable transcript key used by a worker intent. // Worker slots are reusable, so the intent id (rather than work#N) is the // session identity. Keep this helper public so the Worker message API and UI // can refer to exactly the conversation that will be resumed. func WorkerSessionID(explorationID, intentID int64) string { return fmt.Sprintf("exp%d-worker-i%d", explorationID, intentID) } const workerChatMarkerPrefix = "" } func hasWorkerChatMessage(messages []llm.Message, requestID string) bool { marker := workerChatMarker(requestID) for _, message := range messages { if message.Role == llm.RoleUser && strings.Contains(message.Text(), marker) { return true } } return false } // SetNonStreaming wires a resolver deciding whether runs use the non-streaming // model path (true = non-streaming). nil/unset = streaming (default). Read per // run so a profile or task-chain toggle takes effect without rebuilding. func (w *Worker) SetNonStreaming(fn func() bool) { w.nonStreamingFn = fn } func (w *Worker) nonStreaming() bool { return w.nonStreamingFn != nil && w.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 (w *Worker) SetNoaEnabled(fn func() bool) { w.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 (w *Worker) SetMaxTokens(fn func() int) { w.maxTokensFn = fn } func (w *Worker) maxTokens() int { if w.maxTokensFn == nil { return 0 } return w.maxTokensFn() } // SetConstraintInject wires a resolver deciding whether this task's operation // constraints get injected into the worker system prompt. nil = inject (default). func (w *Worker) SetConstraintInject(fn func() bool) { w.injectConstraints = fn } // wantConstraints reports whether constraint injection is enabled (default yes). func (w *Worker) wantConstraints() bool { return w.injectConstraints == nil || w.injectConstraints() } // SetRunTimeout configures the per-intent wall-clock budget for the main // exploration (0 = unlimited). When it fires, the SDK settlement phase still runs // so facts are never lost to a timeout. Safe to call before Execute. func (w *Worker) SetRunTimeout(run time.Duration) { w.runTimeout = run } // settleWrapUpPrompt is injected by the SDK settlement phase when a worker hits its // turn/time budget: stop probing, write back what was found, then end with a // plain-text one-liner (which becomes this run's displayed result). const settleWrapUpPrompt = "本次运行因预算耗尽即将结束。不要再执行任何命令或探测。请按以下顺序处理:(1) 把上面已识别但尚未记录的内容逐条记录。新资产用 insert_assets,探索结论与事实用 record_fact,确认的漏洞用 report_finding 记录。(2) **最后只用一句纯文本**用简体中文总结你做了什么、得到什么核心结论。这一句会作为本次运行的结果显示给用户,必须输出。" func NewWorker(prov llm.Provider, model, workDir string, tx *transcript.Store, window, maxTurns int, extra ...actool.CoreTool) *Worker { return &Worker{prov: prov, model: model, workDir: workDir, tx: tx, window: window, maxTurns: maxTurns, extraTools: extra} } // defaultToolsExcept returns actool.DefaultTools() minus the named tools (by // CoreTool.Name()). Used to trim SDK default tools an agent shouldn't have. func defaultToolsExcept(exclude ...string) []actool.CoreTool { drop := make(map[string]bool, len(exclude)) for _, n := range exclude { drop[n] = true } all := actool.DefaultTools() out := make([]actool.CoreTool, 0, len(all)) for _, t := range all { if !drop[t.Name()] { out = append(out, t) } } return out } func (w *Worker) SetCompactionWindowResolver(fn func() int) { w.windowFn = fn } func (w *Worker) compactionWindow() int { if w.windowFn != nil { return w.windowFn() } return w.window } // SetProxy configures the recording proxy address that workers route target // traffic through, plus the CA cert path WebFetch trusts to verify HTTPS through // that MITM proxy. Empty addr disables the hint. func (w *Worker) SetProxy(addr, caCert string) { w.proxyAddr, w.proxyCACert = addr, caCert } // SetWebSearch selects the web_search backend for this worker (off by default). func (w *Worker) SetWebSearch(o WebSearchOpts) { w.webSearch = o } // proxyEnv builds the Bash-subprocess env that routes child-command HTTP through // the egress proxy (the recording MITM when capture is on, or the global proxy // directly when it is off) and, only when a MITM CA is present, makes the common // toolchain trust it — so tools need no manual -x/--proxy/-k. Each ecosystem reads // a different CA var (verified empirically): SSL_CERT_FILE→curl/urllib/Go/openssl, // REQUESTS_CA_BUNDLE→python requests (it ignores SSL_CERT_FILE), CURL_CA_BUNDLE→curl, // GIT_SSL_CAINFO→git, NODE_EXTRA_CA_CERTS→node; NODE_USE_ENV_PROXY makes Node 24+ // honor the proxy vars. ALL_PROXY is set too so a socks5 egress proxy (which curl // only reads from ALL_PROXY, not HTTP(S)_PROXY) works in the capture-off path. // Empty proxyAddr → nil (direct, unchanged env). func proxyEnv(proxyAddr, caCert string) []string { if proxyAddr == "" { return nil } env := []string{ "HTTP_PROXY=" + proxyAddr, "HTTPS_PROXY=" + proxyAddr, "http_proxy=" + proxyAddr, "https_proxy=" + proxyAddr, "ALL_PROXY=" + proxyAddr, "all_proxy=" + proxyAddr, // socks5 egress: curl reads only this "NODE_USE_ENV_PROXY=1", // Node 24+: honor HTTP(S)_PROXY in built-in fetch/http } if caCert != "" { env = append(env, "SSL_CERT_FILE="+caCert, "CURL_CA_BUNDLE="+caCert, "REQUESTS_CA_BUNDLE="+caCert, "GIT_SSL_CAINFO="+caCert, "NODE_EXTRA_CA_CERTS="+caCert, ) } return env } // workerDefaultTmpl is the built-in EDITABLE body (段 [A]) of the worker system // prompt, seeded into agent_prompts. The trafficTool block and the 中间产物输出规约 // are NOT here — they are code-owned and appended by workerSystem after rendering // (段 [B]/[C]), so editing the DB body can never drop them. const workerDefaultTmpl = `你是一个网络安全平台授权渗透测试系统的"执行者"(work agent)。你领到【一条意图】(一句话探索方向),唯一职责:**完成这一条意图、把发现写回知识图谱、然后停止返回。** **边界(红线)**: 1. **只做你领到的这一条意图**。**探本意图时若瞥见本意图之外值得深挖的线索**(报错泄露的路径、可能与其它资产联动的点、疑似另一条利用链的入口),**在 fact 的 summary 里点一句交给规划者**。 2. 初次受阻(payload 被过滤 / 404 / 注入无回显)不代表已探透——把本意图的所有绕过手段走完再输出结论; 3. 只在授权范围内操作。系统提示顶部若附【操作约束】,那是最高优先级红线:每条命令/探测执行前先自检,违反即不做(哪怕它落在你领到的意图里)。 **边发现边写回**(写进图才算数,脑子/文字里的不算;每得一个结果立刻写,别攒到最后被步数耗尽丢掉)。三种写回,别串图: - **新资产/资源 → insert_assets(资产图)**:子域 / service / endpoint / 指纹 / 凭据 等一切资产【本身】。**这里只登记资产;探索结论/判断不写这里,用 record_fact。** - **探索结论/事实 → record_fact(探索图,传 intent_id)**:都用它。**多个观察汇总成【一条】事实**(summary 一句总结 + detail写对总结的拓展,依靠真实的执行过程),不要一个属性一条、一意图通常只一条,拆碎会让图谱无限膨胀——**默认就写一条,能并进 detail 的都并进去**;仅当确有【彼此完全独立、无法归并】的结论时才用 facts 数组分条,这是极少数例外,不是常规。**只写增量**:只记这次【新得到】的,别把已有事实换措辞重记(只印证已有、无新增就不必记)。**只写真实看到的**:给 evidence(一行:命令+最能证明的一两行输出,简洁,细节在 detail)、标 confidence(observed=直接看到 / inferred=据现象推断)。 - **确认漏洞 → report_finding(探索图,含 PoC,传 intent_id)**:**只有你本次真实触发过、拿到可复现证据(请求/响应或命令输出)才用**。严禁把"版本/指纹匹配到 CVE""参数看起来可注入""外部漏洞库/更新日志/代码 diff 推断"当已确认,也不要用查 CVE 库或对比补丁版本替代实际触发。触发不了但有嫌疑 → 用 record_fact 记一条 inferred 事实(嫌疑点+为何未触发)交规划者,别硬记成 finding。 完成本意图后用一句话总结你做了什么、写回了哪些事实。` // workerTrafficBlock is 段 [B]: the traffic-tool note, code-injected only when // traffic capture (recording) is on — i.e. the traffic_* tools actually exist. // Gated on recording, NOT on the egress proxy: a global proxy with capture off // routes traffic but records nothing, so the tools would not be there. Not stored, // not editable. func workerTrafficBlock(recording bool) string { if !recording { return "" } return "\n\n**流量工具**:\n- traffic_search / traffic_get / traffic_blob:回看响应、找已访问过的资源,**先查流量、不要重复 curl 同一 URL**。traffic_search **必须指定 host**、默认只回 3 条极轻量索引(id/method/url/status/resp_len,无响应内容),需要更多显式调大 limit;可用 body_contains 在请求/响应正文里做全文搜索(至少 3 字符,支持子串和中文,如找密码/密钥/报错/内网地址);要看某条原文用 traffic_get(id),其中超大正文显示为 @blob sha256:,用 traffic_blob(hash) 分段取全文。" } // artifactSpec is 段 [C]: the code-owned, non-editable tail appended to every // pentest agent's prompt — intermediate artifacts must land in the shared work // dir, never /tmp. Guaranteed present regardless of how the DB body is edited. func artifactSpec(dir string) string { return "\n\n**中间产物输出规约**:脚本、payload、抓到的响应体、临时数据等一切中间产物,**一律写到本任务工作目录 " + dir + "**(相对路径即写在这里,也可用该绝对路径)——**不要写 /tmp、不要用其它绝对路径**。" } // workerArtifactSpec is the worker's 段 [C]: its per-intent run dir is pre-created // by the engine (ensureRunDir), so it just writes relative paths there — no manual // mkdir, no cross-worker name collisions. func workerArtifactSpec(runDir string) string { return "\n\n**中间产物输出规约**:脚本、payload、抓到的响应体、临时数据等一切中间产物,**一律写到本次意图的专属工作目录 " + runDir + "**(已自动建好,直接用相对路径写在这里即可,无需再手动建目录)——**不要写 /tmp、不要用其它绝对路径**。" } // ensureRunDir builds and creates an agent's working directory under base: // /tasks/ for planner/main; /tasks//i for a // worker (intentID<=0 → task dir only). The "tasks/" segment groups per-task dirs // symmetrically with the chat agent's "sessions/". Best-effort mkdir — on // failure, writes fail the same way an unwritable CWD would. func ensureRunDir(base string, taskID, intentID int64) string { dir := filepath.Join(base, "tasks", strconv.FormatInt(taskID, 10)) if intentID > 0 { dir = filepath.Join(dir, "i"+strconv.FormatInt(intentID, 10)) } _ = os.MkdirAll(dir, 0o755) return dir } // cmdOutDir is the SDK large-tool-output spill dir under an agent's run dir. func cmdOutDir(dir string) string { return filepath.Join(dir, "cmd-output") } func workerSystem(proxyAddr, caCert, dataDir, runDir string) string { body := renderSystem("worker", workerDefaultTmpl, WorkerVars{ProxyAddr: proxyAddr, DataDir: dataDir, Now: nowStr()}) // caCert is present only when the recording MITM is on, which is exactly when // the traffic_* tools are registered — so it gates the traffic-tool note. // Optional finding guidance is added for every role after tool resolution. return body + workerTrafficBlock(caCert != "") + workerArtifactSpec(runDir) + langDirective() } // renderIntentTask formats the claimed intent for the worker's launch USER message: // the intent is the worker's whole job. It used to live in the system prompt; it now // rides in the first user turn (together with the situational overview) so the system // prompt stays static/role-only — same move as the planner's situational block. // intentAssetIDs pulls the intent's target asset ids out of its payload // (planner's add_intent stores them as a numeric asset_ids array). nil on absence // or malformed payload. func intentAssetIDs(intent *db.Node) []int64 { if intent == nil { return nil } var p struct { AssetIDs []int64 `json:"asset_ids"` } if err := json.Unmarshal(intent.Payload, &p); err != nil { return nil } return p.AssetIDs } func renderIntentTask(intent *db.Node) string { return fmt.Sprintf("\n\n【你领到的意图(本次唯一任务:只做这一条、只产生事实、做完即停)】:\n%s\n意图 id: %d(写回 record_fact / report_finding 时传它)", string(intent.Payload), intent.ID) } // renderWorkerGraphOverview folds the global situational snapshot into the worker's // launch USER message for AWARENESS ONLY. The framing is deliberately strong: the overview // must NOT widen the worker's job — it still does only its assigned intent. Its sole // purpose is letting the worker read context (existing facts/assets/hints) // so it avoids redundant work and doesn't re-derive what others already found. func renderWorkerGraphOverview(data map[string]any) string { // coverage 是给规划者判断「哪类测得少 / 要不要扩范围」的信号,与 worker「只做领到的 // 那条意图、别追未覆盖的点」的职责边界相悖 → 从 worker 视图里剔除。data 是本次 worker // 专属的新 map,删键不影响 planner。 delete(data, "coverage") b, err := json.Marshal(data) if err != nil { return "" // fall back silently: the worker just won't have the global context } return "\n\n【全局探索态势(只读,帮你把自己这条意图放进大局看)】:\n" + "下面是整个任务当前的探索概况。用途有两个:一是知道别人已发现什么,别重复;二是让你探自己这条意图时,能联想到它和全局的关系。\n" + "**发散是好事**:探本意图时尽管深想、多联想。唯一的界线是——别真的动手去执行别的意图(那是别的 worker 的事,由规划者调度)。但凡你联想到有价值的线索(跨资产的联动、疑似另一条利用链的入口、全局层面的可疑点),**务必写进 fact 交规划者**——这是你重要的产出,不是可有可无。宁可多报一条让规划者判断,也别自己咽下去。\n" + string(b) } // Execute runs one intent. hooks (the per-task Guard) gates every tool call; may // be nil. emit, if non-nil, receives one ActivityRecord per execution step. // notifyFinding, if non-nil, is called (intentID, summary) when this worker writes // a finding (report_finding) so the task's planner wakes mid-flight — with context // on which intent found what — instead of waiting for the worker to finish. // Returns the terminal reason (so the engine can distinguish completed vs // max_turns) and a per-kind breakdown of what was written back (so an intent that // explored but persisted nothing isn't mistaken for done, and the engine can log // facts/assets/findings separately instead of lumping them under "facts"). func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, intent *db.Node, hooks harness.HookRunner, emit func(db.Activity), enr EnrichTrigger, notifyFinding func(int64, string)) (harness.TerminalReason, WriteCounts, error) { return w.execute(ctx, name, taskID, as, ts, intent, hooks, emit, enr, notifyFinding, "", "") } // ExecuteWithMessage runs the next turn in the same intent conversation with a // human-authored message. The HTTP handler does not edit the transcript; // agentcore records the message as a normal user turn when this Worker starts. // This keeps Worker continuation identical to the regular agent chat flow. func (w *Worker) ExecuteWithMessage(ctx context.Context, name string, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, intent *db.Node, hooks harness.HookRunner, emit func(db.Activity), enr EnrichTrigger, notifyFinding func(int64, string), requestID, message string) (harness.TerminalReason, WriteCounts, error) { return w.execute(ctx, name, taskID, as, ts, intent, hooks, emit, enr, notifyFinding, strings.TrimSpace(requestID), strings.TrimSpace(message)) } func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, intent *db.Node, hooks harness.HookRunner, emit func(db.Activity), enr EnrichTrigger, notifyFinding func(int64, string), requestID, message string) (harness.TerminalReason, WriteCounts, error) { tsx := NewToolSet(ts, name) tsx.SetFindingRecorder(w.findingRecorder) tsx.SetTaskID(taskID) coverageEnabled := as == nil || as.CoverageEnabled(taskID) tsx.SetCoverageEnabled(coverageEnabled) if as != nil { tsx.SetAssetStore(as, as.Companies()) } tsx.SetOwnerNode(intent.ID) // assets this worker discovers anchor to its intent → visible to the task tsx.SetEnrich(enr) // async DNS/HTTP auto-completion for assets this worker writes tsx.SetNotifyFinding(notifyFinding) // report_finding 落库时当场唤醒 planner,带上「哪个意图+finding」 // base = built-in worker tools ∪ host tools (traffic) ∪ default tools (incl. Bash); // then augment with the agent's visible skills/MCP. During the SDK settlement // phase, Bash is hidden via Settlement.DisabledTools (no local gating needed). base := append(tsx.WorkerTools(), w.extraTools...) // worker 刻意不给 MultiEdit/Glob/Grep:文件精改用 Edit、检索走 Bash(grep/find), // 收敛工具面、减少低价值调用。其余 SDK 默认工具(Read/Write/Edit/LS/Bash/Sleep)照常。 base = append(base, defaultToolsExcept("MultiEdit", "Glob", "Grep")...) ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts), IntentID: intent.ID}) tools, def, cleanup := AugmentTools(ctx, "worker", base) defer cleanup() // 意图是 worker 的【唯一职责、贯穿整个 run 的不变量】→ 连同启动指令、意图锚定的目标资产 // 原始数据一起放进 system prompt:system 每次 run 都重新拼一遍、绝不会被 compaction 压掉, // 长 run 里意图永远在场,续跑时也不依赖 transcript 历史是否留住那条首消息。代价是 system // 混入 per-intent 易变数据、失去跨意图缓存复用;这是刻意的取舍(意图丢失比省 token 严重得多)。 // 与 planner「态势块放 user turn」分叉是有意的:planner 本身是产意图的那个、没有单一 mandate, // worker 有。仅【全局态势 overview】留在启动 user 消息里——它可降级、容忍 stale,压掉无碍。 // 本次意图的专属工作目录 /tasks//i,引擎侧先建好。 runDir := ensureRunDir(w.workDir, taskID, intent.ID) // The run-wide intent is not the current tool action. Do not forward it or // inherit a parent run's background into the action reviewer. ctx = intercept.WithReviewContext(ctx, runDir, intercept.ReviewBackground{}) overview := renderWorkerGraphOverview(tsx.graphOverviewData()) sysBody := workerSystem(w.proxyAddr, w.proxyCACert, w.workDir, runDir) if w.wantConstraints() { sysBody += constraintBlock(ts) // 操作约束(若有)注入系统提示,worker 执行时严格遵守 } // 意图块 → 意图锚定资产块 → 启动指令,依次追加到 system 尾部(与 constraintBlock 同一套追加法)。 sysBody += renderIntentTask(intent) if as != nil { if ids := intentAssetIDs(intent); len(ids) > 0 { if assets, err := as.GetByIDs(ids); err == nil && len(assets) > 0 { if b, err := json.Marshal(assets); err == nil { sysBody += "\n\n本意图 asset_ids 对应的目标资产:\n" + string(b) } // 意图明确针对的这些资产 → 自动纳入任务测试范围(与 insertAssets 同一套 // 保守粒度)。upsertTaskScope 的 ON CONFLICT DO NOTHING + uq_task_scope // 唯一索引保证不会重复添加;重跑/重试同样是幂等 no-op。 // 资产覆盖度功能关闭时不再累积测试范围(分母)。 if coverageEnabled { for _, a := range assets { _ = as.AddAutoScope(taskID, a.Type, a.Domain, a.URL, a.IP) } } } } } sysBody += "\n\n开始执行上面这条意图:只做它、只产生事实、assets、finding、做完即停。" system, boundary := deferredSystem(sysBody, def) // 任务级 deadline(经 ctx 注入)夹逼本 run 的墙钟预算 + 决定收尾词(见 taskclock.go)。 tc := taskClockFrom(ctx) maxDur, clamped := clampMaxDuration(tc.DeadlineUnix, w.runTimeout) settle := wrapupSettlement("worker", []string{"Bash"}) if tc.DeadlineUnix > 0 { settle = wrapupSettlementForTask("worker", []string{"Bash"}, clamped) } opts := agentcore.Options{ Provider: w.prov, SystemPrompt: system, DynamicBoundary: boundary, Tools: tools, DeferredTools: def.Deferred, UnlockSet: def.Unlock, PermissionMode: permission.ModeBypass, // WebFetch 走记录代理,其 HTTP 与 curl 一样被留痕;载入代理 CA 让经 MITM // 重签的 HTTPS 证书能【正常校验通过】(而非关掉校验)。proxy 空则直连。 EnableWebFetch: true, WebFetchProxy: w.proxyAddr, WebFetchCACert: w.proxyCACert, // 联网搜索(可选)。ddgs 无需 key;brave-free 需 BraveKey;tavily 需 TavilyKey。 // WebSearchProxy 是独立的出口代理(http/https/socks5),与记录流量的 MITM 代理无关;空则直连。 EnableWebSearch: w.webSearch.Enabled, WebSearchBackend: w.webSearch.Backend, BraveSearchAPIKey: w.webSearch.BraveKey, TavilySearchAPIKey: w.webSearch.TavilyKey, DeepSeekSearchBaseURL: w.webSearch.DeepSeekBaseURL, DeepSeekSearchAPIKey: w.webSearch.DeepSeekAPIKey, DeepSeekSearchModel: w.webSearch.DeepSeekModel, WebSearchProxy: w.webSearch.Proxy, // Bash 子命令的 HTTP 默认走记录代理 + 信任其 CA(工具无需 -x/-k)。 BashEnv: proxyEnv(w.proxyAddr, w.proxyCACert), WorkingDir: runDir, MaxTurns: w.maxTurns, // 0 = unlimited (configurable in agent management) // 墙钟预算,轮边界判,不打断半路;0 = 不限。有任务级 deadline 时夹逼到 min(自身预算, // 距 deadline 剩余),让本 run 在任务到点时自然进收尾(见 taskclock.go)。 MaxDuration: maxDur, // 命中预算(轮次 OR 时长)→ SDK 跑一轮收尾(隐藏 Bash),把已识别的写回,避免烂尾。 // clamped(被任务 deadline 夹逼)时用 PromptByReason:因超时=任务到点→任务超时词, // 因步数=夹逼窗口内步数先耗尽→回落 per-run 词。非 clamped 维持纯 per-run。 Settlement: settle, // large tool output spills to cmd-output/ with a head + pointer (SDK tool.Capture); // full output preserved on disk. 截断上限用 SDK 默认(30000 字符)。 ToolOutputDir: cmdOutDir(runDir), Compaction: compactionConfig(w.compactionWindow()), // long tool-heavy runs stay within the window Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢 NonStreaming: w.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete MaxTokens: w.maxTokens(), // 0 = 不发上限,由服务端默认值决定 } if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic) opts.Hooks = hooks } if w.tx != nil { // persist raw LLM conversation; one file per worked intent opts.Transcript = w.tx opts.SessionID = WorkerSessionID(ts.ID(), intent.ID) } intentID := intent.ID emitWrap := func(r db.Activity) { if emit != nil { r.NodeID, r.Worker = &intentID, name emit(r) } } // 意图 / 启动指令 / 意图锚定资产已随 system prompt 下发(见上方 sysBody 组装)。 // 这条启动 user 消息只承载【全局态势 overview】——可降级的了解大局信息,压掉无碍。 // overview 罕见地 marshal 失败为空时,回退一句启动词,避免首轮出现空 user 消息。 input := overview if strings.TrimSpace(input) == "" { input = "开始执行 system 里领到的意图:只做它、只产生事实、assets、finding、做完即停。" } // 实验功能:开启后由 noa 接管上下文压缩(归档集中在 /noa/ 下,持久)。 noaSession := WorkerSessionID(ts.ID(), intent.ID) enableNoa(&opts, w.noaEnabledFn, w.workDir, noaSession, noaWarn(noaSession)) ctx = attachSideCapture(ctx, &opts) s := agentcore.NewSession(opts) defer s.Close() // release the session's background-task manager (temp dir + processes) // Resume prior conversation if this intent was paused/blocked/exhausted and is // being re-run. The transcript ID is deterministic per intent, so if a prior // session exists the worker continues from where it left off instead of // restarting from scratch. alreadyRecorded := false if w.tx != nil { _ = s.Resume(opts.SessionID) alreadyRecorded = requestID != "" && hasWorkerChatMessage(s.Messages(), requestID) if len(s.Messages()) > 0 && message == "" { seedUnlockFromHistory(s.Messages(), def.UnlockSkill) input = "继续执行。" } else if len(s.Messages()) > 0 { seedUnlockFromHistory(s.Messages(), def.UnlockSkill) } } if message != "" { if alreadyRecorded { input = "继续执行上一次人工对话输入的新意图。不要重复已经完成的动作。" } else if len(s.Messages()) > 0 { input = workerChatMarker(requestID) + "\n【人工对话输入的新意图】\n" + message + "\n\n请立即按这条人工输入执行,完成后再根据上下文决定原任务是否需要继续。" } else { input += "\n\n" + workerChatMarker(requestID) + "\n【人工对话输入的新意图】\n" + message + "\n\n请优先执行这条人工输入。" } } // Budgets + settlement are owned by the SDK (MaxTurns/MaxDuration + Settlement): // on hit it runs a wrap-up turn and finishes with ReasonMaxTurns/ReasonTimeout. // MaxDuration now interrupts an in-flight tool at the wall-clock deadline and // enters the wrap-up phase on the live ctx, so a run whose tool overran the budget // still settles (no external hard-timeout backstop needed). ctx itself carries only // pause / planner kill / shutdown, which the engine distinguishes and re-queues/stops. _, reason, err := captureRunSession(ctx, s, input, emitWrap) return reason, tsx.Writes(), err }