Files
ARTEX/agent/mainagent.go
T
Autumn-27 52aba07846 feat(mainagent): 默认给主 agent 加上 steer_work 工具
主 agent 之前只能 add_hint/add_intent,无法对已在运行的 work 实时纠偏。
现将 planner 的 steer_work(不打断、不丢进展地注入纠偏指令)默认下放给主 agent。

- MainAgentTools 加入 steerWorkTool
- MainAgent 加 steerWork 字段与 SetSteerWork,Chat 里接进 ToolSet
- 全局与 per-task router 两处主 agent 构建均 SetSteerWork(engine.SteerWork)
- 主 agent 提示词「操舵」段补充 steer_work 用法引导(仅影响新 seed)

作用域安全:steer_work 仍校验 intent_id 属于本任务(关联任务只读)。

Claude-Session: https://claude.ai/code/session_019QXSCDmzimwnjntBNCgUD7
2026-08-23 10:37:24 -04:00

154 lines
8.4 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"
"fmt"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/llm"
"github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool"
"github.com/Autumn-27/norma/transcript"
)
// MainAgent is the thin human-interface orchestrator (docs §4.2 / §7). The human
// chats with it; it observes (read tools), and steers by injecting hints
// (→planner) or direct high-priority intents (→frontier). It does NOT run the
// autonomous intent-generation loop (that is the planner's job).
type MainAgent struct {
prov llm.Provider
model string
tx *transcript.Store // raw LLM conversation persistence (nil = off)
window int // context window in tokens (for compaction)
windowFn func() int // optional dynamic task-chain minimum
maxTurns int // max agent turns per run (0 = unlimited)
proxyAddr string // recording proxy for WebFetch (empty = direct)
proxyCACert string // recording proxy's CA cert path (HTTPS verify)
webSearch WebSearchOpts // web_search tool backend selection (off by default)
workDir string // shared work dir (surfaced in prompt as artifact-output target)
steerWork func(intentID int64, msg string) error // engine callback: steer a running work (nil = off)
}
func NewMainAgent(prov llm.Provider, model, workDir string, tx *transcript.Store, window, maxTurns int) *MainAgent {
return &MainAgent{prov: prov, model: model, workDir: workDir, tx: tx, window: window, maxTurns: maxTurns}
}
func (m *MainAgent) SetCompactionWindowResolver(fn func() int) { m.windowFn = fn }
func (m *MainAgent) compactionWindow() int {
if m.windowFn != nil {
return m.windowFn()
}
return m.window
}
// SetProxy points the main agent's WebFetch at the recording proxy plus the CA
// cert it trusts to verify HTTPS through it (empty addr = direct).
func (m *MainAgent) SetProxy(addr, caCert string) { m.proxyAddr, m.proxyCACert = addr, caCert }
// SetWebSearch selects the web_search backend for the main agent (off by default).
func (m *MainAgent) SetWebSearch(o WebSearchOpts) { m.webSearch = o }
// SetSteerWork wires the engine callback that lets the main agent's steer_work
// tool inject a mid-run course-correction into a running work (nil = tool off).
func (m *MainAgent) SetSteerWork(fn func(intentID int64, msg string) error) { m.steerWork = fn }
// mainAgentDefaultTmpl is the built-in EDITABLE body (段 [A]) of the main agent
// prompt, seeded into agent_prompts. Goal is a {{.Goal}} template var; the 中间
// 产物输出规约 tail is code-owned (artifactSpec), appended after rendering.
const mainAgentDefaultTmpl = `你是一个授权渗透测试系统的"主 agent",是人类操作员的接口。你不亲自探索、也不自主连续生成意图(那是规划者的工作)。你的职责:
1. 观察:用 graph_overview / list_findings / list_facts / list_assets / get_worker_output 回答人关于当前进展的问题。
2. 操舵(把人的意图落到系统):
- 人想"改方向/强调某类漏洞/重点某区域" → 用 add_hint 写提示(规划者下次会读到)。
- 人想"立刻测某个具体目标" → 用 add_intent 直接注入一条高优先级意图(priority 8-10)。
- 人想"对某条正在运行的意图(work)实时纠偏(别再走 X、聚焦 Y)" → 用 steer_work(不打断、不丢已有进展,worker 下一步动作前生效);先用 get_worker_output 看它在干嘛。方向整个错了则改用 add_intent 另下新意图。
- 人想"新增一个要达成的最终目标" → 用 set_goals 增补目标。系统会把该目标写入任务图并**自动把已完成/暂停的任务拉回运行态继续跑**(规划者随后会据此重新判断是否达成),无需人工再点恢复。
3. 用人话简洁回复,说明你做了什么。
当前任务目标:{{.Goal}}
不要编造发现;只根据工具返回的真实数据回答。`
func mainAgentSystem(goal, dataDir, workDir string) string {
body := renderSystem("mainagent", mainAgentDefaultTmpl, MainVars{Goal: goal, DataDir: dataDir, Now: nowStr()})
return body + artifactSpec(workDir)
}
// Chat handles one human message and returns the assistant reply. emit, if
// non-nil, receives each execution step (thinking / tool_use / tool_result /
// text / result) so the main-agent session shows its work — exactly like the
// worker/planner sessions — not just the final answer.
func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, goal, message string, emit func(db.Activity), notify, resume func(), notifyGoal func([]string)) (string, error) {
tsx := NewToolSet(ts, "human")
if as != nil {
tsx.SetAssetStore(as, as.Companies())
}
tsx.SetTaskID(taskID)
tsx.SetCoverageEnabled(as == nil || as.CoverageEnabled(taskID))
tsx.SetNotify(notify) // add_hint wakes this task's planner (debounced)
tsx.SetResumeTask(resume) // set_goals 新增目标 → 把已完成/暂停的任务拉回 running
tsx.SetNotifyGoal(notifyGoal) // set_goals 新增目标 → 给 planner 记一条「人新增了目标:…」触发
tsx.steerWork = m.steerWork // enable steer_work tool (nil = unavailable)
// 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash)
// 资产覆盖度功能关闭时剔除 add_task_scope/list_untested_assets(不入 prompt)。
base := append(tsx.DropCoverageTools(tsx.MainAgentTools()), actool.DefaultTools()...)
ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts)})
tools, def, cleanup := AugmentTools(ctx, "mainagent", base)
tools = tsx.StripCoverageParams(tools) // 覆盖度关闭时隐藏 insert_assets 的 related 入参
defer cleanup()
// 本任务的工作目录 <workDir>/tasks/<taskID>,先建好。
mainDir := ensureRunDir(m.workDir, taskID, 0)
system, boundary := deferredSystem(mainAgentSystem(goal, m.workDir, mainDir), def)
opts := agentcore.Options{
Provider: m.prov,
SystemPrompt: system,
DynamicBoundary: boundary,
Tools: tools,
DeferredTools: def.Deferred,
UnlockSet: def.Unlock,
PermissionMode: permission.ModeBypass,
EnableWebFetch: true, // 走记录代理留痕;载入代理 CA 验证 MITM 重签的 HTTPS 证书
WebFetchProxy: m.proxyAddr,
WebFetchCACert: m.proxyCACert,
// 联网搜索(可选)。ddgs 无需 key;brave-free 需 BraveKey;tavily 需 TavilyKey。
// WebSearchProxy 是独立出口代理(http/https/socks5),与记录流量的 MITM 代理无关;空则直连。
EnableWebSearch: m.webSearch.Enabled,
WebSearchBackend: m.webSearch.Backend,
BraveSearchAPIKey: m.webSearch.BraveKey,
TavilySearchAPIKey: m.webSearch.TavilyKey,
WebSearchProxy: m.webSearch.Proxy,
BashEnv: proxyEnv(m.proxyAddr, m.proxyCACert), // Bash 子命令默认走代理+信任 CA
WorkingDir: mainDir, // 本任务工作目录 <workDir>/tasks/<taskID>
ToolOutputDir: cmdOutDir(mainDir),
MaxTurns: m.maxTurns, // 0 = unlimited (configurable in agent management)
Compaction: compactionConfig(m.compactionWindow()), // long chats stay within the window
Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢
// 命中预算(步数)→ SDK 跑收尾:向用户输出一句进展总结。Prompt 与收尾轮数可后台编辑(默认 10 轮)。
Settlement: wrapupSettlement("mainagent", nil),
}
if m.tx != nil { // persist raw human↔AI conversation; one accumulating file per task
opts.Transcript = m.tx
opts.SessionID = fmt.Sprintf("exp%d-main", ts.ID())
}
s := agentcore.NewSession(opts)
defer s.Close()
// reload the prior conversation from the transcript so the agent has context
// across turns (each Chat is a fresh session; without this it can't see earlier
// messages). First turn: no file yet → Resume loads nothing and proceeds.
if m.tx != nil {
_ = s.Resume(opts.SessionID)
}
// C2: this session is fresh each turn; re-unlock skill-gated MCPs from prior
// Skill() calls in the reloaded history so revealed tools stay callable.
seedUnlockFromHistory(s.Messages(), def.UnlockSkill)
text, _, err := captureRunSession(ctx, s, message, func(r db.Activity) {
if emit != nil {
r.Worker = "mainagent"
emit(r)
}
})
return text, err
}