Files
artex/agent/worker.go
T
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

536 lines
31 KiB
Go
Raw 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"
"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 = "<!-- ARTEX_WORKER_CHAT:"
func workerChatMarker(requestID string) string {
return workerChatMarkerPrefix + requestID + " -->"
}
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:<hash>,用 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:
// <base>/tasks/<taskID> for planner/main; <base>/tasks/<taskID>/i<intentID> for a
// worker (intentID<=0 → task dir only). The "tasks/" segment groups per-task dirs
// symmetrically with the chat agent's "sessions/<sessionID>". 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,压掉无碍。
// 本次意图的专属工作目录 <workDir>/tasks/<taskID>/i<intentID>,引擎侧先建好。
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 接管上下文压缩(归档集中在 <workDir>/noa/<SessionID> 下,持久)。
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
}