Files
ARTEX-chixuweihu/agent/chat.go
T
Autumn-27 7cc467c159 feat(llm): 模型配置新增输出上限,支持 max_completion_tokens 字段名
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 约束限定取值,每次启动先删后建保持幂等。
2026-08-31 12:20:31 -04:00

177 lines
8.0 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"
"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
}