First Commit
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
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
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
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
This commit is contained in:
+535
@@ -0,0 +1,535 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user