Files
opsxdb/server/internal/ai/harness.go
T
logan d543f3b924 feat(ai): session persist + continue HTTP/UI, checkpoints, queue
落地 docs/16 P1:会话落盘与真续跑(prior tool 轨迹)、incomplete「加预算继续」、
awaiting_confirm outcome、同参拒+第4/6轮检查点、侧栏忙时单槽排队。
Ask 真 token 流式记缺口(三协议 SSE 成本高)。
2026-10-15 14:18:18 +08:00

178 lines
4.3 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 ai
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"strings"
)
// HarnessConfig Agent 运行时预算(桌面裁剪版;不做 Evolver)。
type HarnessConfig struct {
MaxRounds int // 默认 8,硬帽 16
MaxToolResultChars int // 回灌截断
MaxContextTokens int // trim 预算(粗估字符/4)
SameCallLimit int // 同工具同参上限;0=关闭
MinRetainMessages int // trim 至少保留条数(不含 system)
}
// DefaultHarness 与历史 agent.go 常量对齐。
func DefaultHarness() HarnessConfig {
return HarnessConfig{
MaxRounds: defaultAgentMaxRounds,
MaxToolResultChars: maxToolResultFeedChars,
MaxContextTokens: 8000,
SameCallLimit: 3,
MinRetainMessages: 4,
}
}
func (h HarnessConfig) ClampRounds(n int) int {
if n <= 0 {
n = h.MaxRounds
if n <= 0 {
n = defaultAgentMaxRounds
}
}
if n > 16 {
n = 16
}
return n
}
// NewRunID 生成短 run_id,贯穿审计与步骤。
func NewRunID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return "run_" + hex.EncodeToString(b[:])
}
// EstimateTokens 粗估(字符/4);含 tool_calls JSON。
func EstimateTokens(msgs []Message) int {
n := 0
for _, m := range msgs {
n += len(m.Content)
if len(m.ToolCalls) > 0 {
if b, err := json.Marshal(m.ToolCalls); err == nil {
n += len(b)
}
}
n += len(m.Name) + len(m.ToolCallID) + 8
}
if n < 0 {
return 0
}
return n / 4
}
// TrimMessages 保留 system + 最近消息,控制在 MaxContextTokens*90%;
// 并按 tool_call_id 整组保留 assistant.tool_calls 与对应 tool。
func TrimMessages(msgs []Message, h HarnessConfig) []Message {
if len(msgs) == 0 {
return msgs
}
budget := h.MaxContextTokens
if budget <= 0 {
budget = 8000
}
limit := budget * 90 / 100
minRetain := h.MinRetainMessages
if minRetain <= 0 {
minRetain = 4
}
var system []Message
var rest []Message
for _, m := range msgs {
if m.Role == "system" {
system = append(system, m)
} else {
rest = append(rest, m)
}
}
if EstimateTokens(msgs) <= limit {
return ensureToolPairing(msgs)
}
// 从尾部向前取,直到接近预算
var kept []Message
for i := len(rest) - 1; i >= 0; i-- {
candidate := append([]Message{}, rest[i:]...)
candidate = append(append([]Message{}, system...), candidate...)
if EstimateTokens(candidate) > limit && len(kept) >= minRetain {
break
}
kept = rest[i:]
if len(kept) >= minRetain && EstimateTokens(candidate) <= limit {
break
}
}
if len(kept) == 0 && len(rest) > 0 {
// 至少留最后一条
kept = rest[len(rest)-1:]
}
out := append(append([]Message{}, system...), kept...)
return ensureToolPairing(out)
}
// ensureToolPairing 丢弃没有对应 assistant tool_calls 的孤立 tool,以及裁切后半截的 tool_calls。
func ensureToolPairing(msgs []Message) []Message {
toolIDs := map[string]bool{}
for _, m := range msgs {
if m.Role == "assistant" {
for _, tc := range m.ToolCalls {
if tc.ID != "" {
toolIDs[tc.ID] = true
}
}
}
}
out := make([]Message, 0, len(msgs))
for _, m := range msgs {
if m.Role == "tool" {
if m.ToolCallID == "" || !toolIDs[m.ToolCallID] {
continue
}
}
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
// 若后续缺少任一 tool 结果,整组 assistant 仍保留(由调用方补 incomplete);
// 这里只滤 tool 侧孤儿。
}
out = append(out, m)
}
return out
}
// ToolCallFingerprint 同参循环检测键。
func ToolCallFingerprint(name, argsJSON string) string {
name = strings.TrimSpace(name)
argsJSON = strings.Join(strings.Fields(argsJSON), " ")
return name + "\n" + argsJSON
}
// DetectRepeatedToolCall 若同一指纹已出现 limit 次(不含本次),返回 true。
func DetectRepeatedToolCall(history []string, fp string, limit int) bool {
if limit <= 0 || fp == "" {
return false
}
n := 0
for _, h := range history {
if h == fp {
n++
}
}
return n >= limit
}
// CheckpointHint 中段检查点(第 4 / 6 轮,1-based);空串表示本轮无注入。
func CheckpointHint(round1Based int) string {
switch round1Based {
case 4:
return "[检查点] 若已有足够信息请直接作答;避免重复相同参数的工具调用。"
case 6:
return "[检查点] 请尽快收尾给出结论,勿再扩大探索范围。"
default:
return ""
}
}