mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Comments, doc comments, UI labels, log and error messages, LLM prompt strings, markdown docs, test data and assertions. Identifiers, enum keys, routes, placeholders and code structure are unchanged. Notes: - docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md - tests that exercised CJK specific handling (GBK zip names, Chinese full text search) now use ASCII equivalents; go build passes and the test suite matches the pre translation baseline (remaining failures need a PostgreSQL DSN and fail identically on the original) - frontend mention syntax now uses English kinds (finding, asset, ...) matching the backend, chat mentions tests pass
563 lines
33 KiB
Go
563 lines
33 KiB
Go
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.
|
||
//
|
||
// Note the deepseek backend differs in nature from the other three: DeepSeek has no
|
||
// directly callable search API — search exists only inside its Anthropic-compatible
|
||
// messages API (the web_search_20250305 server tool), so each search consumes one
|
||
// model call, and the search request is issued by DeepSeek's servers — it does NOT
|
||
// go through the local Proxy, nor into the traffic recording.
|
||
type WebSearchOpts struct {
|
||
Enabled bool
|
||
Backend string
|
||
BraveKey string
|
||
TavilyKey string
|
||
Proxy string
|
||
// DeepSeek* comes from the currently active LLM config (only the official
|
||
// DeepSeek endpoint in anthropic format) — not configured separately; it follows
|
||
// the LLM config switch.
|
||
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 = "You are about to be terminated because your budget is exhausted. Do not run any more commands/probes. Please, in order: (1) write back everything you have identified above but not yet written back, item by item — new assets with insert_assets, exploration conclusions/facts with record_fact, confirmed findings with report_finding; (2) **finally, in a single standalone sentence of plain text** summarize what you did and which key conclusions you reached (this sentence is shown as this run's result — be sure to output it)."
|
||
|
||
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 (section [A]) of the worker
|
||
// system prompt, seeded into agent_prompts. The trafficTool block and the
|
||
// intermediate artifact output rules are NOT here — they are code-owned and
|
||
// appended by workerSystem after rendering (sections [B]/[C]), so editing the DB
|
||
// body can never drop them.
|
||
const workerDefaultTmpl = `You are an "executor" (work agent) of a network-security platform's authorized penetration testing system. You are assigned [ONE intent] (a one-sentence exploration direction), and your sole duty is: **complete this one intent, write the findings back into the knowledge graph, then stop and return.**
|
||
|
||
**Boundaries (red lines)**:
|
||
1. **Do only the one intent you were assigned**. **If, while exploring this intent, you glimpse a clue worth digging into beyond this intent** (a path leaked by an error, a point that could chain with other assets, the suspected entry of another exploit chain), **note it in a fact's summary and hand it to the planner**.
|
||
2. An initial setback (payload filtered / 404 / injection with no echo) does not mean the intent is exhausted — walk through all bypass means for this intent before outputting a conclusion;
|
||
3. Operate only within the authorized scope. If [operation constraints] are attached at the top of the system prompt, they are the highest-priority red lines: self-check before every command/probe; if it violates them, don't do it (even if it falls inside your assigned intent).
|
||
|
||
**Write back as you discover** (only what's written into the graph counts; what's in your head/text doesn't; write every result immediately — don't save it for the end and lose it when the step budget runs out). Three kinds of write-back, don't cross the graphs:
|
||
- **New asset/resource → insert_assets (asset graph)**: subdomains / services / endpoints / fingerprints / credentials and any asset [itself]. **Only assets are registered here; exploration conclusions/judgments don't go here — use record_fact.**
|
||
- **Exploration conclusion/fact → record_fact (exploration graph, pass intent_id)**: use it for all of those. **Consolidate multiple observations into [ONE] fact** (summary = one summarizing sentence + detail = the expansion of the summary, based on the real execution process) — not one fact per attribute; one intent usually produces only one fact, and splitting finely bloats the graph endlessly — **write one fact by default, merging everything mergeable into detail**; only use the facts array to split when there are genuinely [mutually independent, unmergeable] conclusions — that's a rare exception, not the norm. **Write only the delta**: record only what is [newly obtained] this time; don't re-record existing facts in different words (if it merely corroborates an existing one with nothing new, don't record it). **Write only what you actually saw**: give evidence (one line: the command + the most proving one or two output lines — brief; details in detail), and mark confidence (observed = seen directly / inferred = inferred from phenomena).
|
||
- **Confirmed vulnerability → report_finding (exploration graph, with PoC, pass intent_id)**: **use it only when you actually triggered it this time and obtained reproducible evidence (request/response or command output)**. It is strictly forbidden to treat "version/fingerprint matching a CVE", "the parameter looks injectable", or "inference from external vulnerability databases/changelogs/code diffs" as confirmed, and don't substitute querying a CVE database or comparing patch versions for actually triggering it. Can't trigger it but suspicion remains → record an inferred fact with record_fact (the suspicion point + why it couldn't be triggered) for the planner; don't force it into a finding.
|
||
|
||
|
||
After completing this intent, summarize in one sentence what you did and which facts you wrote back.`
|
||
|
||
// workerTrafficBlock is section [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**Traffic tools**:\n- traffic_search / traffic_get / traffic_blob: review responses, find already-visited resources — **check the traffic first, don't curl the same URL twice**. traffic_search **requires a host** and by default returns only 3 ultra-light index entries (id/method/url/status/resp_len, no response content) — explicitly raise limit when you need more; body_contains does full-text search in request/response bodies (at least 3 characters; supports substrings and CJK text, e.g. for passwords/keys/errors/internal addresses); to see one entry's raw content use traffic_get(id) — oversized bodies show as @blob sha256:<hash>, fetched in segments with traffic_blob(hash)."
|
||
}
|
||
|
||
// artifactSpec is section [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**Intermediate artifact output rules**: all intermediate artifacts — scripts, payloads, captured response bodies, temporary data — **must be written to this task's work directory " + dir + "** (a relative path writes here; the absolute path works too) — **do NOT write to /tmp, do NOT use other absolute paths**."
|
||
}
|
||
|
||
// workerArtifactSpec is the worker's section [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**Intermediate artifact output rules**: all intermediate artifacts — scripts, payloads, captured response bodies, temporary data — **must be written to this intent's dedicated work directory " + runDir + "** (already created automatically; just write with relative paths there — no manual mkdir needed) — **do NOT write to /tmp, do NOT use other absolute paths**."
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// 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[The intent you claimed (your only job this time: do just this one, produce only facts, stop when done)]:\n%s\nIntent id: %d (pass it when writing back 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 is a signal for the planner to judge "which class is under-tested /
|
||
// whether to widen the scope" — it conflicts with the worker's boundary of "do
|
||
// only the assigned intent, don't chase uncovered points" → drop it from the
|
||
// worker view. data is a fresh map dedicated to this worker, so deleting the key
|
||
// doesn't affect the 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[Global exploration overview (read-only, helps you place your assigned intent in the bigger picture)]:\n" +
|
||
"Below is the task's current exploration overview. Two purposes: first, know what others have already found — don't repeat; second, let you relate this intent to the global picture while exploring it.\n" +
|
||
"**Digressing is good**: while exploring this intent, think deep and associate freely. The only boundary is — don't actually go execute other intents (that's other workers' business, scheduled by the planner). But whenever you associate a valuable clue (cross-asset linkage, the suspected entry of another exploit chain, a global-level suspicious point), **be sure to write it into a fact for the planner** — this is an important output of yours, not optional. Better to report one more for the planner to judge than to swallow it yourself.\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) // when report_finding lands, wake the planner on the spot with "which intent + 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...)
|
||
// The worker deliberately gets no MultiEdit/Glob/Grep: precise file edits use Edit,
|
||
// searching goes through Bash(grep/find) — narrowing the tool surface and reducing
|
||
// low-value calls. The rest of the SDK default tools (Read/Write/Edit/LS/Bash/Sleep)
|
||
// are kept as usual.
|
||
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()
|
||
|
||
// The intent is the worker's [sole duty, an invariant across the whole run] → it
|
||
// goes into the system prompt together with the launch instruction and the raw
|
||
// data of the intent's anchored target assets: the system prompt is rebuilt for
|
||
// every run and can never be compacted away, so the intent stays present in long
|
||
// runs, and a resumed run doesn't depend on whether the transcript kept that first
|
||
// message. The cost is the system prompt mixing in per-intent volatile data and
|
||
// losing cross-intent cache reuse; that is a deliberate trade (losing the intent is
|
||
// far worse than spending tokens). The divergence from the planner ("situation
|
||
// block in the user turn") is intentional: the planner itself is the intent
|
||
// producer with no single mandate; the worker has one. Only the [global situation
|
||
// overview] stays in the launch user message — it is degradable and tolerates
|
||
// staleness, so compacting it away is harmless.
|
||
// This intent's dedicated work dir <workDir>/tasks/<taskID>/i<intentID> is created
|
||
// by the engine first.
|
||
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) // inject the operation constraints (if any) into the system prompt; the worker strictly obeys them during execution
|
||
}
|
||
// intent block → intent-anchored assets block → launch instruction, appended to
|
||
// the system tail in that order (same append pattern as 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\nThe target assets corresponding to this intent's asset_ids:\n" + string(b)
|
||
}
|
||
// The assets this intent explicitly targets → automatically join the task's
|
||
// test scope (same conservative granularity as insertAssets). upsertTaskScope's
|
||
// ON CONFLICT DO NOTHING + the uq_task_scope unique index guarantee no
|
||
// duplicate additions; reruns/retries are idempotent no-ops as well.
|
||
// When the asset-coverage feature is off, the test scope (denominator) is no
|
||
// longer accumulated.
|
||
if coverageEnabled {
|
||
for _, a := range assets {
|
||
_ = as.AddAutoScope(taskID, a.Type, a.Domain, a.URL, a.IP)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
sysBody += "\n\nStart executing the intent above: do only it, produce only facts, assets, findings, stop when done."
|
||
system, boundary := deferredSystem(sysBody, def)
|
||
// The task-level deadline (injected via ctx) clamps this run's wall-clock budget +
|
||
// decides the wrap-up prompt (see 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 goes through the recording proxy, so its HTTP is recorded just like
|
||
// curl; loading the proxy CA lets MITM-re-signed HTTPS certs [verify normally]
|
||
// (rather than disabling verification). Direct when proxy is empty.
|
||
EnableWebFetch: true,
|
||
WebFetchProxy: w.proxyAddr,
|
||
WebFetchCACert: w.proxyCACert,
|
||
// Web search (optional). ddgs needs no key; brave-free needs BraveKey; tavily needs TavilyKey.
|
||
// WebSearchProxy is a dedicated egress proxy (http/https/socks5), unrelated to the traffic-recording MITM proxy; empty = direct.
|
||
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 subcommands' HTTP goes through the recording proxy + trusts its CA by
|
||
// default (tools need no -x/-k).
|
||
BashEnv: proxyEnv(w.proxyAddr, w.proxyCACert),
|
||
WorkingDir: runDir,
|
||
MaxTurns: w.maxTurns, // 0 = unlimited (configurable in agent management)
|
||
// Wall-clock budget, judged at turn boundaries without interrupting mid-flight;
|
||
// 0 = unlimited. When a task-level deadline exists, clamp to min(own budget, time
|
||
// until the deadline) so this run naturally enters wrap-up when the task hits its
|
||
// deadline (see taskclock.go).
|
||
MaxDuration: maxDur,
|
||
// Hitting the budget (turns OR duration) → the SDK runs a wrap-up round (with
|
||
// Bash hidden) to write back what's identified, avoiding unfinished tails.
|
||
// When clamped (by the task deadline), use PromptByReason: timeout = task
|
||
// deadline → task-timeout prompt; turns = the turn budget ran out inside the
|
||
// clamped window → fall back to the per-run prompt. Non-clamped stays pure per-run.
|
||
Settlement: settle,
|
||
// large tool output spills to cmd-output/ with a head + pointer (SDK tool.Capture);
|
||
// full output preserved on disk. The truncation cap uses the SDK default (30000
|
||
// chars).
|
||
ToolOutputDir: cmdOutDir(runDir),
|
||
Compaction: compactionConfig(w.compactionWindow()), // long tool-heavy runs stay within the window
|
||
Todos: actool.NewTodoStore(), // session-level scratch todos (TodoWrite), pure planning, discarded on exit
|
||
NonStreaming: w.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete
|
||
MaxTokens: w.maxTokens(), // 0 = send no cap; the server default decides
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
// The intent / launch instruction / intent-anchored assets were already delivered
|
||
// with the system prompt (see the sysBody assembly above). This launch user message
|
||
// carries only the [global situation overview] — degradable big-picture info, safe
|
||
// to compact away. In the rare case the overview marshals to empty, fall back to a
|
||
// launch sentence so the first turn isn't an empty user message.
|
||
input := overview
|
||
if strings.TrimSpace(input) == "" {
|
||
input = "Start executing the intent claimed in system: do only it, produce only facts, assets, findings, stop when done."
|
||
}
|
||
|
||
// Experimental: when on, noa takes over context compression (archives persist under <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 = "Continue."
|
||
} else if len(s.Messages()) > 0 {
|
||
seedUnlockFromHistory(s.Messages(), def.UnlockSkill)
|
||
}
|
||
}
|
||
if message != "" {
|
||
if alreadyRecorded {
|
||
input = "Continue executing the new intent entered in the previous human conversation. Do not repeat actions already completed."
|
||
} else if len(s.Messages()) > 0 {
|
||
input = workerChatMarker(requestID) + "\n[New intent entered via human conversation]\n" + message +
|
||
"\n\nExecute this human input immediately; after completing it, decide from the context whether the original task needs to continue."
|
||
} else {
|
||
input += "\n\n" + workerChatMarker(requestID) + "\n[New intent entered via human conversation]\n" + message +
|
||
"\n\nExecute this human input first."
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|