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
192 lines
9.1 KiB
Go
192 lines
9.1 KiB
Go
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", "capture response bodies") that would be odd in a general assistant.
|
|
func chatWorkDirSpec(workDir string) string {
|
|
return "\n\n**File output rules**: whenever you need to write a file, write it to the working directory " + workDir + " (this is the default CWD — relative paths land here, and you may also use this absolute path) — do not write to /tmp or other absolute paths."
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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, // routed through the recording proxy for tracing; loads the proxy CA to verify MITM-resigned HTTPS certificates
|
|
WebFetchProxy: c.proxyAddr,
|
|
WebFetchCACert: c.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: 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 subcommands go through the proxy + trust the CA by default
|
|
WorkingDir: sessionWorkDir,
|
|
MaxTurns: maxTurns,
|
|
MaxDuration: maxDuration,
|
|
Compaction: compactionConfig(c.window),
|
|
Todos: actool.NewTodoStore(),
|
|
// large tool output spills to cmd-output/ under the session dir.
|
|
// the truncation cap uses the SDK default (30000 chars of tool.Capture).
|
|
ToolOutputDir: filepath.Join(sessionWorkDir, "cmd-output"),
|
|
// hitting the budget (steps) → the SDK runs wrap-up: output a one-line summary. The prompt and
|
|
// wrap-up rounds are editable in the backend per agent key
|
|
// (each custom agent gets its own copy; empty/0 uses the generic default: 10 rounds).
|
|
Settlement: wrapupSettlement(agentKey, nil),
|
|
NonStreaming: c.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete
|
|
MaxTokens: c.maxTokens(), // 0 = send no cap, the server's default decides
|
|
}
|
|
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
|
|
}
|
|
// experimental feature: when enabled, noa takes over context compression (archive centralized under <workDir>/noa/<SessionID>, persistent).
|
|
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
|
|
}
|