mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
llm_profiles 加两列:max_tokens(单次回复输出上限,0=不发送该字段,由服务端 默认值决定)与 max_tokens_field(上限发哪个键)。后者仅对 format=openai 生效: 空(默认)= max_tokens;'max_completion_tokens' = 新键。OpenAI 推理模型 (o 系列/GPT-5)拒收 max_tokens、只认新键,而多数兼容网关反过来只认旧键, 故做成显式开关而非自动推断。anthropic 与 openai-responses 自带字段名, 存下来只会误导,保存时一并清空。 顺带补上一处从未接通的链路:planner/worker/chat/mainagent/goals 五个 agentcore.Options 都没设过 MaxTokens,恒为 0——openai 系因 omitempty 压根 不发上限,anthropic 走 norma 的 8192 兜底。光加配置项而不接这条线,用户在 后台填了数字也不会生效,故本次一并接通,取值与 streaming 一样按 profile 每轮解析,故障转移切换后下一轮即生效。 task_llm.go 里 nonStreaming() 的来源优先级(agent 绑定 → 任务链激活 profile → 全局)抽成共用的 activeCfg(),避免 maxTokens() 再抄一份同样的三级回退。 norma 升到 v0.3.3(该版本提供 Config.MaxTokensField)。go.mod 只动 norma 这 一行:仓库此前未 tidy 过,跑 go mod tidy 会顺带升 x/crypto、x/net 等一批无关 依赖,不混进本次改动。 迁移:新列走 ADD COLUMN IF NOT EXISTS + 默认 0/'',旧库升级后行为完全不变; CHECK 约束限定取值,每次启动先删后建保持幂等。
177 lines
8.0 KiB
Go
177 lines
8.0 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/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)
|
||
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() }
|
||
|
||
// 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)
|
||
}
|
||
|
||
// 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)
|
||
|
||
// 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,
|
||
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
|
||
}
|
||
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
|
||
}
|