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:
+190
@@ -0,0 +1,190 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/guard"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ChatAgent is the generic, task-independent conversational runner behind the chat
|
||||
// page. It generalizes MainAgent.Chat: any agent (built-in OR a custom one, by
|
||||
// key) can be chatted with, multi-turn history resumed from the transcript. It is
|
||||
// a PURE ASSISTANT — base tools are the SDK DefaultTools (Bash/Read/Write/Edit/
|
||||
// LS/Glob/Grep) plus whatever skills/MCP the key is made visible; NO pentest
|
||||
// graph/task context is injected (that stays exclusive to MainAgent).
|
||||
type ChatAgent struct {
|
||||
prov llm.Provider
|
||||
model string
|
||||
workDir string
|
||||
tx *transcript.Store
|
||||
window int
|
||||
proxyAddr string
|
||||
proxyCACert string
|
||||
webSearch WebSearchOpts
|
||||
guard *guard.Guard // optional; nil disables intercept hooks for chat
|
||||
nonStreamingFn func() bool // resolver: use non-streaming (Complete) path? (nil = streaming)
|
||||
noaEnabledFn func() bool // resolver: use experimental noa compaction? (nil = off)
|
||||
maxTokensFn func() int // resolver: per-reply output cap (nil/0 = send no cap)
|
||||
}
|
||||
|
||||
func NewChatAgent(prov llm.Provider, model, workDir string, tx *transcript.Store, window int) *ChatAgent {
|
||||
return &ChatAgent{prov: prov, model: model, workDir: workDir, tx: tx, window: window}
|
||||
}
|
||||
|
||||
// SetNonStreaming wires a resolver deciding whether chat runs use the
|
||||
// non-streaming model path (true = non-streaming). nil/unset = streaming.
|
||||
func (c *ChatAgent) SetNonStreaming(fn func() bool) { c.nonStreamingFn = fn }
|
||||
|
||||
func (c *ChatAgent) nonStreaming() bool { return c.nonStreamingFn != nil && c.nonStreamingFn() }
|
||||
|
||||
// SetNoaEnabled wires a resolver deciding whether chat 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 (c *ChatAgent) SetNoaEnabled(fn func() bool) { c.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 (c *ChatAgent) SetMaxTokens(fn func() int) { c.maxTokensFn = fn }
|
||||
|
||||
func (c *ChatAgent) maxTokens() int {
|
||||
if c.maxTokensFn == nil {
|
||||
return 0
|
||||
}
|
||||
return c.maxTokensFn()
|
||||
}
|
||||
|
||||
// SetProxy points the chat agent's WebFetch/Bash at the recording proxy plus the
|
||||
// CA cert it trusts (empty addr = direct). Kept for parity with the other agents.
|
||||
func (c *ChatAgent) SetProxy(addr, caCert string) { c.proxyAddr, c.proxyCACert = addr, caCert }
|
||||
|
||||
// SetWebSearch selects the web_search backend for the chat agent (off by default).
|
||||
func (c *ChatAgent) SetWebSearch(o WebSearchOpts) { c.webSearch = o }
|
||||
|
||||
// SetGuard attaches a guard (with user-configured intercept rules) to this chat
|
||||
// agent. Must be called before Chat; safe to call multiple times.
|
||||
func (c *ChatAgent) SetGuard(g *guard.Guard) { c.guard = g }
|
||||
|
||||
// chatWorkDirSpec returns a working-directory notice appended to every chat
|
||||
// agent's system prompt. Mirrors artifactSpec but without pentest-specific
|
||||
// wording ("payload", "抓响应体") that would be odd in a general assistant.
|
||||
func chatWorkDirSpec(workDir string) string {
|
||||
return "\n\n**文件输出规约**:需要写文件时,一律写到工作目录 " + workDir + "(这是默认 CWD,相对路径即落在这里,也可用该绝对路径)——不要写 /tmp 或其他绝对路径。"
|
||||
}
|
||||
|
||||
// chatSystem renders the DB-managed prompt body for agentKey. Custom agents have
|
||||
// no per-key in-code default, so DefaultAssistantPrompt is the render fallback.
|
||||
func chatSystem(agentKey, dataDir, workDir string) string {
|
||||
return renderSystem(agentKey, DefaultAssistantPrompt, chatVars{DataDir: dataDir, Now: nowStr()}) + chatWorkDirSpec(workDir) + langDirective()
|
||||
}
|
||||
|
||||
// chatVars carries the runtime variables a custom agent's prompt may reference.
|
||||
// DataDir (server data root) + Now (server wall-clock, refreshed each turn) are the
|
||||
// universal ones; any other {{.X}} fails to render and falls back to
|
||||
// DefaultAssistantPrompt.
|
||||
type chatVars struct{ DataDir, Now string }
|
||||
|
||||
// Chat runs ONE turn of a conversation with the agent identified by agentKey,
|
||||
// resuming prior history keyed by sessionID. maxTurns is the per-turn agent step
|
||||
// budget (0 = unlimited). maxDuration is the wall-clock run budget per turn
|
||||
// (0 = unlimited); the timer resets each time Chat is called, so a new user
|
||||
// message always starts a fresh countdown. webSearch gates network search for
|
||||
// THIS agent (the global backend/key still come from the chat agent's config,
|
||||
// but each agent decides on/off). emit receives each execution step (thinking /
|
||||
// tool_use / tool_result / text / result), tagged with the agent key as the
|
||||
// worker lane.
|
||||
func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message string, maxTurns int, maxDuration time.Duration, webSearch bool, emit func(db.Activity)) (string, error) {
|
||||
// gate the global web-search opts by this agent's own flag.
|
||||
ws := c.webSearch
|
||||
if !webSearch {
|
||||
ws.Enabled = false
|
||||
}
|
||||
|
||||
// Per-session working directory: <workDir>/sessions/<sessionID>/
|
||||
// Isolates file writes across conversations, mirroring how workers use i<intentID>/.
|
||||
sessionWorkDir := filepath.Join(c.workDir, "sessions", sessionID)
|
||||
_ = os.MkdirAll(sessionWorkDir, 0o755)
|
||||
ctx = intercept.WithReviewWorkingDirectory(ctx, sessionWorkDir)
|
||||
|
||||
// Pure assistant: DefaultTools as the base; AugmentTools layers in the key's
|
||||
// visible skills/MCP and lets the DB tools table filter/override. DefaultTools
|
||||
// have no tools-table rows, so they always pass through.
|
||||
base := actool.DefaultTools()
|
||||
ctx = WithRunInfo(ctx, RunInfo{SessionID: sessionID})
|
||||
tools, def, cleanup := AugmentTools(ctx, agentKey, base)
|
||||
defer cleanup()
|
||||
|
||||
system, boundary := deferredSystem(chatSystem(agentKey, c.workDir, sessionWorkDir), def)
|
||||
opts := agentcore.Options{
|
||||
Provider: c.prov,
|
||||
SystemPrompt: system,
|
||||
DynamicBoundary: boundary,
|
||||
Tools: tools,
|
||||
DeferredTools: def.Deferred,
|
||||
UnlockSet: def.Unlock,
|
||||
PermissionMode: permission.ModeBypass,
|
||||
EnableWebFetch: true, // 走记录代理留痕;载入代理 CA 验证 MITM 重签的 HTTPS 证书
|
||||
WebFetchProxy: c.proxyAddr,
|
||||
WebFetchCACert: c.proxyCACert,
|
||||
// 联网搜索(可选)。ddgs 无需 key;brave-free 需 BraveKey;tavily 需 TavilyKey。
|
||||
// WebSearchProxy 是独立出口代理(http/https/socks5),与记录流量的 MITM 代理无关;空则直连。
|
||||
EnableWebSearch: ws.Enabled,
|
||||
WebSearchBackend: ws.Backend,
|
||||
BraveSearchAPIKey: ws.BraveKey,
|
||||
TavilySearchAPIKey: ws.TavilyKey,
|
||||
DeepSeekSearchBaseURL: ws.DeepSeekBaseURL,
|
||||
DeepSeekSearchAPIKey: ws.DeepSeekAPIKey,
|
||||
DeepSeekSearchModel: ws.DeepSeekModel,
|
||||
WebSearchProxy: ws.Proxy,
|
||||
BashEnv: proxyEnv(c.proxyAddr, c.proxyCACert), // Bash 子命令默认走代理+信任 CA
|
||||
WorkingDir: sessionWorkDir,
|
||||
MaxTurns: maxTurns,
|
||||
MaxDuration: maxDuration,
|
||||
Compaction: compactionConfig(c.window),
|
||||
Todos: actool.NewTodoStore(),
|
||||
// large tool output spills to cmd-output/ under the session dir.
|
||||
// 截断上限用 SDK 默认(tool.Capture 的 30000 字符)。
|
||||
ToolOutputDir: filepath.Join(sessionWorkDir, "cmd-output"),
|
||||
// 命中预算(步数)→ SDK 跑收尾:输出一句总结。Prompt 与收尾轮数按本 agent key 后台可编辑
|
||||
// (自定义 agent 各自一份;留空/0 用通用默认:10 轮)。
|
||||
Settlement: wrapupSettlement(agentKey, nil),
|
||||
NonStreaming: c.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete
|
||||
MaxTokens: c.maxTokens(), // 0 = 不发上限,由服务端默认值决定
|
||||
}
|
||||
if c.guard != nil {
|
||||
opts.Hooks = c.guard.Hooks()
|
||||
}
|
||||
if c.tx != nil { // persist raw human↔AI conversation; one accumulating file per thread
|
||||
opts.Transcript = c.tx
|
||||
opts.SessionID = sessionID
|
||||
}
|
||||
// 实验功能:开启后由 noa 接管上下文压缩(归档集中在 <workDir>/noa/<SessionID> 下,持久)。
|
||||
enableNoa(&opts, c.noaEnabledFn, c.workDir, "chat-"+sessionID, noaWarn("chat-"+sessionID))
|
||||
ctx = attachSideCapture(ctx, &opts)
|
||||
s := agentcore.NewSession(opts)
|
||||
defer s.Close()
|
||||
// reload prior conversation so the agent has context across turns (each Chat is
|
||||
// a fresh session). First turn: no file yet → Resume loads nothing and proceeds.
|
||||
if c.tx != nil {
|
||||
_ = s.Resume(sessionID)
|
||||
}
|
||||
// re-unlock skill-gated MCPs from prior Skill() calls in the reloaded history so
|
||||
// revealed tools stay callable across the fresh session.
|
||||
seedUnlockFromHistory(s.Messages(), def.UnlockSkill)
|
||||
text, _, err := captureRunSession(ctx, s, message, func(r db.Activity) {
|
||||
if emit != nil {
|
||||
r.Worker = agentKey
|
||||
emit(r)
|
||||
}
|
||||
})
|
||||
return text, err
|
||||
}
|
||||
Reference in New Issue
Block a user