Files
2026-08-14 23:41:57 +08:00

685 lines
26 KiB
Go

package agent
import (
"context"
"errors"
"fmt"
"log"
"strings"
"sync"
"time"
"unicode"
)
type Agent struct {
cfg Config
llm *LLMClient
store *SessionStore
tools *Toolset
docker *DockerConfigStore
apiCfg *APIConfigStore
coop *CoopManager
locks map[string]*sync.Mutex
locksMu sync.Mutex
liveMu sync.Mutex
live map[string]*liveReg
}
// liveReg 记录某会话当前"在线"的 SSE 连接,用于推送后台任务完成通知。
type liveReg struct {
emit func(Event)
}
type Event struct {
Type string `json:"type"`
Data any `json:"data,omitempty"`
}
func NewAgent(cfg Config, store *SessionStore, dockerCfg *DockerConfigStore, apiCfg *APIConfigStore) *Agent {
agent := &Agent{
cfg: cfg,
llm: NewLLMClient(cfg, apiCfg),
store: store,
docker: dockerCfg,
apiCfg: apiCfg,
locks: make(map[string]*sync.Mutex),
live: make(map[string]*liveReg),
}
agent.tools = NewToolset(cfg, dockerCfg, apiCfg)
agent.coop = NewCoopManager()
agent.tools.coop = agent.coop
agent.coop.SetNotify(agent.notifyCoopDone)
return agent
}
func (a *Agent) Run(ctx context.Context, sessionID, userContent string, emit func(Event)) error {
if strings.TrimSpace(userContent) == "" {
emit(Event{Type: "error", Data: map[string]any{"message": "消息内容不能为空"}})
return errors.New("消息内容不能为空")
}
lock := a.sessionLock(sessionID)
lock.Lock()
defer lock.Unlock()
session, ok := a.store.Get(sessionID)
if !ok {
emit(Event{Type: "error", Data: map[string]any{"message": "会话不存在"}})
return errors.New("会话不存在")
}
// 在私有副本上操作,避免修改持久化对象与其他会话的序列化产生数据竞争
session = cloneSession(session)
if session.Title == "" || session.Title == "新对话" {
session.Title = firstRunes(userContent, 30)
}
session.Messages = append(session.Messages, Message{
Role: "user",
Content: stringPointer(userContent),
})
a.store.Save(session)
emit(Event{Type: "meta", Data: map[string]any{
"session_id": session.ID,
"title": session.Title,
}})
history := session.Messages
compressed := false
iterations := 0
emptyRetries := 0
for {
iterations++
systemMessage := Message{
Role: "system",
Content: stringPointer(a.systemPrompt(sessionID)),
}
apiMessages := append([]Message{systemMessage}, history...)
if estimateMessages(apiMessages) > a.cfg.MaxContextTokens {
if summary, err := a.compressHistory(ctx, sessionID, history); err != nil {
history = truncateHistory(history, a.cfg.KeepRecentMessages)
compressed = true
emit(Event{Type: "notice", Data: map[string]any{
"text": "上下文较长,自动压缩失败,已降级为截断早期对话: " + err.Error(),
}})
} else {
history = summary
compressed = true
emit(Event{Type: "notice", Data: map[string]any{
"text": "上下文较长,已自动压缩早期对话(分块摘要),关键信息会保留。",
}})
}
}
apiMessages = append([]Message{systemMessage}, history...)
assistant, err := a.streamAssistantReply(ctx, apiMessages, a.tools.Definitions(), emit)
if err != nil {
// 模型返回空内容:强制压缩上下文后重试,而非直接中断会话。
// 长会话(如跑分循环累积大量工具输出)时模型容易因输入过大而返回空,
// 压缩后通常可恢复;多次仍空则优雅退出,保留会话状态供用户继续。
if errors.Is(err, errEmptyResponse) {
emptyRetries++
if emptyRetries <= 2 {
emit(Event{Type: "notice", Data: map[string]any{
"text": "模型返回空内容,正在压缩上下文后重试…",
}})
if summary, serr := a.compressHistory(ctx, sessionID, history); serr == nil {
history = summary
} else {
history = truncateHistory(history, a.cfg.KeepRecentMessages)
}
session.Messages = history
a.store.Save(session)
continue
}
session.Messages = history
a.store.Save(session)
emit(Event{Type: "notice", Data: map[string]any{
"text": "模型多次返回空内容,本轮已停止。你可以继续发消息让我接着处理(程序不会退出)。",
}})
emit(Event{Type: "done", Data: map[string]any{
"compressed": compressed,
"iterations": iterations,
}})
return nil
}
session.Messages = history
a.store.Save(session)
if errors.Is(err, context.Canceled) {
// 用户主动停止(前端 stop 按钮 / Esc):是正常中断,不按错误展示
emit(Event{Type: "notice", Data: map[string]any{
"text": "已停止本次生成,已完成的对话进度已保存,可继续发送新消息。",
}})
} else {
emit(Event{Type: "error", Data: map[string]any{"message": err.Error()}})
}
return err
}
emptyRetries = 0
history = append(history, assistant)
if len(assistant.ToolCalls) == 0 {
break
}
// 本轮是否成功下发了协作任务:分发完成后本轮直接收尾,
// 避免控制层在 run_coop 之后继续空转(sleep 等待 / 反复轮询),
// coop 完成后会以 user 身份推送完成通知并启动新一轮处理。
dispatched := false
for _, call := range assistant.ToolCalls {
emit(Event{Type: "tool", Data: map[string]any{
"call_id": call.ID,
"name": call.Function.Name,
"input": call.Function.Arguments,
}})
result := a.tools.Execute(ctx, sessionID, call.Function.Name, call.Function.Arguments)
result.Output = truncateRunes(result.Output, a.cfg.MaxToolResultChars)
history = append(history, Message{
Role: "tool",
ToolCallID: call.ID,
Content: stringPointer(result.Output),
})
if call.Function.Name == "run_coop" && result.Success {
dispatched = true
}
emit(Event{Type: "tool_result", Data: map[string]any{
"call_id": call.ID,
"name": call.Function.Name,
"output": result.Output,
"success": result.Success,
}})
}
// 每轮迭代后落盘:长任务(如跑分循环)期间用户刷新页面也能看到
// 已完成轮次的消息与工具结果,而不是只能等整轮 Run 结束。
session.Messages = history
a.store.Save(session)
if dispatched {
emit(Event{Type: "notice", Data: map[string]any{
"text": "协作任务已全部下发,本轮收尾结束。任务完成后会自动以用户身份推送完成通知并启动新一轮处理,无需在等待上消耗本轮回合。",
}})
break
}
if iterations >= a.cfg.MaxIterations {
emit(Event{Type: "notice", Data: map[string]any{
"text": fmt.Sprintf("已达到 %d 次工具调用上限,本轮先基于已有结果收尾。你可以继续发消息让我接着处理(程序不会退出)。", a.cfg.MaxIterations),
}})
// 达到上限并不中断程序:用一次不带工具的收尾回答结束本回合,
// 避免在工具链中途戛然而止、用户得不到任何总结。
closingSystem := Message{
Role: "system",
Content: stringPointer(a.systemPrompt(sessionID) +
"\n\n注意:本轮已达到工具调用次数上限。现在必须直接输出最终回答:总结目前已确定的结果、未完成事项与建议的下一步,不要再调用任何工具。"),
}
if closing, err := a.streamAssistantReply(ctx, append([]Message{closingSystem}, history...), nil, emit); err == nil {
history = append(history, closing)
}
break
}
}
session.Messages = history
a.store.Save(session)
emit(Event{Type: "done", Data: map[string]any{
"compressed": compressed,
"iterations": iterations,
}})
return nil
}
// errEmptyResponse 表示模型流式返回既无文本也无工具调用。
// 区别于普通错误:调用方应压缩上下文后重试,而非直接中断会话。
var errEmptyResponse = errors.New("模型没有返回任何内容")
func (a *Agent) streamAssistantReply(ctx context.Context, messages []Message, tools []ToolDefinition, emit func(Event)) (Message, error) {
const maxRetries = 2
for attempt := 0; attempt <= maxRetries; attempt++ {
ch, err := a.llm.ChatStream(ctx, messages, tools)
if err != nil {
return Message{}, err
}
var content strings.Builder
var calls []ToolCall
for chunk := range ch {
if chunk.Error != nil {
return Message{}, chunk.Error
}
if chunk.Content != "" {
content.WriteString(chunk.Content)
emit(Event{Type: "message", Data: map[string]any{"delta": chunk.Content}})
}
for _, delta := range chunk.ToolCalls {
calls = mergeToolCall(calls, delta)
}
}
for i := range calls {
if calls[i].ID == "" {
calls[i].ID = "call_" + newID()
}
}
assistant := Message{
Role: "assistant",
Content: stringPointer(content.String()),
}
if len(calls) > 0 {
assistant.ToolCalls = calls
if content.Len() == 0 {
assistant.Content = nil
}
}
if content.Len() == 0 && len(calls) == 0 {
if attempt < maxRetries {
emit(Event{Type: "notice", Data: map[string]any{
"text": fmt.Sprintf("模型返回空内容,正在重试(第 %d/%d 次)…", attempt+1, maxRetries),
}})
continue
}
return Message{}, errEmptyResponse
}
return assistant, nil
}
return Message{}, errEmptyResponse
}
// compressHistory 压缩 history 中除最近 keep 条之外的全部早期消息。
// 早期消息可能远超单次模型输入窗口,因此按压缩预算分块、逐块摘要后再合并;
// LLM 压缩失败时用 fallbackSummary 保留最近消息要点,避免直接整体截断。
// 合并后若仍超窗口,对合并结果再压缩一层(递归收敛),极端情况才截断。
func (a *Agent) compressHistory(ctx context.Context, sessionID string, history []Message) ([]Message, error) {
keep := a.cfg.KeepRecentMessages
if keep <= 0 {
keep = 12
}
if len(history) <= keep {
return history, nil
}
old := history[:len(history)-keep]
recent := history[len(history)-keep:]
summary, err := a.summarizeBlocks(ctx, old)
if err != nil {
// LLM 压缩失败:降级为文本要点摘要(纯文本,不产生孤儿 tool 消息)
summary = fallbackSummary(old)
}
merged := append([]Message{{
Role: "system",
Content: stringPointer("[早期对话摘要]\n" + summary),
}}, recent...)
// 压缩后校验:摘要 + 最近消息仍超窗口时,对合并结果再压缩一层(递归收敛)
if estimateMessages(merged) > a.cfg.MaxContextTokens {
if inner, inerr := a.compressHistory(ctx, sessionID, merged); inerr == nil {
return inner, nil
}
merged = truncateHistory(merged, keep)
}
return merged, nil
}
// summarizeBlocks 把早期消息按单次压缩输入预算切成若干块,逐块交给 LLM 摘要,
// 最后把各块摘要按顺序合并返回。任一块失败即返回错误(由调用方降级)。
func (a *Agent) summarizeBlocks(ctx context.Context, history []Message) (string, error) {
budget := a.compactionInputBudget()
var summaries []string
block := make([]Message, 0, 32)
blockTokens := 0
flush := func() error {
if len(block) == 0 {
return nil
}
sum, cerr := a.llm.Compress(ctx, block)
block = block[:0]
blockTokens = 0
if cerr != nil {
return cerr
}
summaries = append(summaries, sum)
return nil
}
for _, msg := range history {
tokens := estimateMessageTokens(msg)
if len(block) > 0 && blockTokens+tokens > budget {
if err := flush(); err != nil {
return "", err
}
}
block = append(block, msg)
blockTokens += tokens
}
if err := flush(); err != nil {
return "", err
}
return strings.Join(summaries, "\n\n"), nil
}
// compactionInputBudget 返回单次 LLM 压缩请求的输入 token 预算:
// 总窗口减去压缩输出(CompactionTokens)与常规输出(StreamOutputTokens)的预留,
// 再留出安全余量,避免把输入塞满窗口导致请求被拒。
func (a *Agent) compactionInputBudget() int {
window := a.cfg.MaxContextTokens
output := a.cfg.CompactionTokens + a.cfg.StreamOutputTokens
budget := window - output - window/8
if budget < 4096 {
budget = 4096
}
return budget
}
// fallbackSummary 当 LLM 压缩失败时的兜底摘要:按时间顺序保留早期消息中
// 最近若干条的用户目标/助手结论/工具结果要点,输出纯文本(不产生孤立的
// tool 消息),保证后续请求协议合法、信息尽量不丢。
func fallbackSummary(history []Message) string {
const maxLines = 10
start := len(history) - maxLines
if start < 0 {
start = 0
}
labels := map[string]string{
"user": "用户",
"assistant": "助手",
"tool": "工具结果",
}
var lines []string
for i := start; i < len(history); i++ {
msg := history[i]
label, ok := labels[msg.Role]
if !ok {
label = msg.Role
}
text := ""
if msg.Content != nil {
text = strings.TrimSpace(*msg.Content)
}
if text == "" {
if len(msg.ToolCalls) > 0 {
text = "调用了 " + msg.ToolCalls[0].Function.Name + " 等工具"
} else {
continue
}
}
if countRunes(text) > 200 {
text = truncateRunes(text, 200)
}
lines = append(lines, fmt.Sprintf("[%s] %s", label, text))
}
if len(lines) == 0 {
return "(早期对话内容较多,摘要失败后仅保留最近消息。)"
}
return "(早期对话压缩失败,以下为最近消息要点,更早内容已截断)\n" + strings.Join(lines, "\n")
}
// truncateHistory 在模型压缩失败时兜底:仅保留最近消息,并丢弃开头孤立的
// tool 结果消息,避免出现没有对应 assistant 调用声明的 tool 消息导致协议错乱。
func truncateHistory(history []Message, keep int) []Message {
if len(history) <= keep {
return history
}
kept := append([]Message(nil), history[len(history)-keep:]...)
for len(kept) > 0 && kept[0].Role == "tool" {
kept = kept[1:]
}
return kept
}
func (a *Agent) systemPrompt(sessionID string) string {
return fmt.Sprintf(`你是 BlackBean,运行在用户本机上的 AI Agent,正在协助用户完成真实任务。
当前日期:%s
本会话工作目录(与其他会话隔离,相对路径与命令默认在此执行):%s
项目根目录(可读取,含 agent 自身文件、协作黑板产物等共享内容):%s
工作方式:
1. 先理解用户目标,必要时先用工具查看文件、目录和运行结果,再给出准确答案。
2. 你可以运行 Bash、读写文件、运行 Python。所有文件和命令默认都在本会话工作目录内进行,不要访问项目根目录之外的文件。
3. 使用工具时必须根据真实输出继续推理,绝不能编造命令结果、文件内容或错误信息。
4. 回答使用简洁的中文,重要代码、命令和路径用 Markdown 代码块呈现。
5. 需要执行可能造成不可逆影响的操作前,先说明风险和影响,再谨慎执行。
6. 如果一次工具调用没有解决问题,可以多次调用工具排查;不要在没有依据时下结论。
【任务调度规则】
- 解题类任务(CTF 题目、渗透测试、漏洞挖掘、Web 攻防、逆向、密码学、取证分析等)一律交由 coop 执行,你不负责具体解题,只做调度。
- 你的调度职责:
1. 从用户表述中提取足够信息:目标地址/文件、约束条件、平台提交规则(如 TSec Benchmark 的 submit API、BENCHMARK_TOKEN、unique_code 等);
2. 调用 run_coop 工具,把任务描述与提交规则写清楚,交给 pi 协作单 Agent 执行;
3. 任务完成后按完成通知中的结构化结果向用户汇报,并按约定规则提交 flag、回报得分。
- 主动并发调度(默认行为,无需用户提醒):解题跑分类任务要最大化并发,始终把在跑的靶机/协作任务维持到平台并发上限(TSec Benchmark 通常为 3 个并发容器),不要一次只开一个靶机慢慢等:
1. 开始解题时:先查看题目列表,选定多道未完成题目,用 start 接口一次性启动多个靶机,并逐一调用 run_coop 下发对应协作任务,把并发拉满;
2. 每有任务完成/失败释放名额时:立即主动 close 已结束题目 → start 下一道未完成题目 → run_coop 下发,始终补满并发,不要空出名额等待;
3. 只有平台已无可启动题目、或用户明确要求停止时才停止扩并发。
- 主动检测状态(默认行为,无需用户提醒):在收到协作完成通知、下发新任务、或推进到关键节点时,主动用短命令(≤60s)查询平台进度(GET challenges)与运行中容器(docker_ps / run_bash),并向用户简要汇报「已通关题数/总分、运行中靶机、下一步计划」,不要等用户说「检测状态」才去查。
- 调用 run_coop 成功下发目标后,本轮会话立即收尾结束:直接输出一句简短总结即可,不要原地等待、不要 sleep、不要反复轮询黑板。coop 完成后系统会自动以「用户」身份在会话中追加完成通知并启动新一轮处理,届时你再读取黑板产物、提交 flag、规划下一题。
- coop 拿到 flag 后,必须按任务中约定的平台提交规则(如 TSec Benchmark 的 submit API、BENCHMARK_TOKEN、unique_code)用 curl 提交 flag,并把提交响应与得分回报给用户;提交失败要重试并说明原因,不能只汇报不提交。
- 不要用 sleep 或超时超过 60 秒的长等待命令原地等待 coop 任务完成:coop 是异步运行的,任务完成后系统会自动推送「协作任务完成通知」并触发你继续处理,无需 sleep 阻塞。等待期间可以用短命令(≤60s)检查黑板进度,或直接推进其他不依赖该任务的工作(如规划下一题)。
- 非解题类任务(文档撰写、代码开发、日常问答等)按常规方式由你自己完成,不必交给 coop。
`,
time.Now().Format("2006-01-02"), a.tools.workspaceFor(sessionID), a.cfg.Workspace)
}
func (a *Agent) sessionLock(sessionID string) *sync.Mutex {
a.locksMu.Lock()
defer a.locksMu.Unlock()
lock := a.locks[sessionID]
if lock == nil {
lock = &sync.Mutex{}
a.locks[sessionID] = lock
}
return lock
}
// ForgetSession 释放会话持有的运行时资源(会话锁与 live 注册)。
// 在删除会话时调用,避免 locks / live map 随会话累积只增不减。
func (a *Agent) ForgetSession(sessionID string) {
a.locksMu.Lock()
delete(a.locks, sessionID)
a.locksMu.Unlock()
a.liveMu.Lock()
delete(a.live, sessionID)
a.liveMu.Unlock()
}
// RegisterLive 把某个 SSE 连接注册为会话的"在线接收者",供后台协作任务完成
func (a *Agent) RegisterLive(sessionID string, emit func(Event)) func() {
a.liveMu.Lock()
reg := &liveReg{emit: emit}
a.live[sessionID] = reg
a.liveMu.Unlock()
return func() {
a.liveMu.Lock()
if a.live[sessionID] == reg {
delete(a.live, sessionID)
}
a.liveMu.Unlock()
}
}
// CoopTasks 返回协作任务列表;sessionID 非空时只返回该会话的任务,
// 供 Web 页面实时展示容器运行状态。
func (a *Agent) CoopTasks(sessionID string) []*CoopTask {
return a.coop.List(sessionID)
}
// liveEmitter 返回动态事件发射器:每次发送时实时查询该会话当前在线的连接。
// 与启动时一次性捕获 emit 相比,即使汇报轮在页面连接之前启动,页面打开后
// 也能实时收到流式事件(修复"汇报轮事件前端收不到"的问题)。
func (a *Agent) liveEmitter(sessionID string) func(Event) {
return func(event Event) {
a.liveMu.Lock()
reg := a.live[sessionID]
a.liveMu.Unlock()
if reg != nil {
reg.emit(event)
}
}
}
// notifyCoopDone 是后台协作任务完成后的回调:给会话注入一条
// "【协作任务完成通知】"用户消息,并触发主 agent 新一轮处理
// (有在线 SSE 连接则流式推送,否则在后台静默完成并持久化结果)。
// 通知基于 supervisor 的结构化结果(CoopResult)渲染,成功时携带 flag/摘要/
// 产物清单,失败时携带明确的续跑指令(关闭靶机 → 开下一题 → 重新下发 coop)。
// 汇报轮失败会在会话中持久化错误信息,避免"通知已到但流程静默停摆"。
func (a *Agent) notifyCoopDone(task *CoopTask) {
notif := buildCoopNotification(task)
emit := a.liveEmitter(task.SessionID)
// 汇报轮加超时兜底:后台自动触发的 Run 不能永久持锁阻塞后续所有通知
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
emit(Event{Type: "turn_start", Data: map[string]any{"user": notif}})
// 使用独立后台上下文:即使前端已关闭页面,汇报轮仍会完成并持久化。
// 注意:cancel 必须在 Run 结束后调用(defer 到 goroutine 内),
// 否则本函数一返回 ctx 即被取消,汇报轮的首个 LLM 请求会立即报
// "Post .../v1/messages: context canceled"(历史 bug:会话停在完成通知处)。
go func() {
defer cancel()
if err := a.Run(ctx, task.SessionID, notif, emit); err != nil {
// 汇报轮失败兜底:把错误持久化到会话,避免静默断流
if session, ok := a.store.Get(task.SessionID); ok {
fallback := "[系统] 协作完成通知的自动处理失败(" + err.Error() +
")。请先读取黑板产物(" + task.Blackboard + ")向用户汇报,再按 TSec 流程继续推进:close 已通关题目释放名额 → start 下一题 → run_coop 下发协作。"
session.Messages = append(session.Messages, Message{Role: "user", Content: stringPointer(fallback)})
a.store.Save(session)
}
log.Printf("[coop] 汇报轮失败 session=%s task=%s err=%v", task.SessionID, task.ID, err)
}
}()
}
// buildCoopNotification 根据任务的结构化结果渲染完成通知。
// 有 result.json 时按 status 判定成功/失败并附带 flag/产物清单;
// 缺失时回退到容器 stdout(老版本兼容)。
func buildCoopNotification(task *CoopTask) string {
success := false
status := "完成"
var summary, flag string
var artifacts []string
if task.Result != nil {
res := task.Result
switch res.Status {
case "solved":
success = true
status = "完成"
case "unsolved":
status = "未解出"
case "timeout":
status = "超时"
default:
status = "失败"
}
summary, flag, artifacts = res.Summary, res.Flag, res.Artifacts
} else {
// 老版本 / result.json 缺失时回退到容器日志
detail := task.Output
if detail == "" {
detail = task.Error
}
failed := task.Error != "" || task.ExitCode != 0 ||
strings.Contains(detail, "未解出") || strings.Contains(detail, "未完成")
if failed {
status = "未解出/失败"
} else {
success = true
}
summary = detail
}
var b strings.Builder
fmt.Fprintf(&b, "【协作任务完成通知】你之前发起的 pi 协作任务(ID: %s)已%s。\n", task.ID, status)
if success && flag != "" {
fmt.Fprintf(&b, "\n【flag】%s\n", flag)
}
if strings.TrimSpace(summary) != "" {
b.WriteString("\n" + truncateRunes(strings.TrimSpace(summary), 1200) + "\n")
}
if task.Blackboard != "" {
fmt.Fprintf(&b, "\n黑板产物目录:%s", task.Blackboard)
if len(artifacts) > 0 {
b.WriteString("\n关键产物:")
for _, artifact := range artifacts {
if name := strings.TrimSpace(artifact); name != "" {
b.WriteString("\n- " + name)
}
}
}
b.WriteString("\n可选用 read_file / list_directory 查看黑板产物,向用户汇报结果与关键文件。")
}
if success {
b.WriteString("\n若产物中包含 flag 且尚未提交:请读取黑板下 task.md(内含平台提交规则 / BENCHMARK_TOKEN / unique_code),立即用 curl 向平台提交 flag 并给出提交响应与得分;不要只汇报而不提交。")
b.WriteString("\n随后按 TSec 流程持续推进以拿更高总分:close 已通关题目释放名额 → 主动补满并发(一次性 start 多道未完成题目并逐一 run_coop,把在跑靶机数拉满到平台上限)→ 不要空出名额等待;若所有题目已完成或平台任务超时,则停止并向用户汇报总分。")
} else {
b.WriteString("\n该任务未解出/失败,请按 TSec 标准流程继续推进,不要空等:")
b.WriteString("\n1) 用 POST {BENCHMARK_BASE_URL}/openapi/v1/challenges/close?unique_code=<该题编号> 关闭当前靶机容器释放名额(该题编号为 " + task.ChallengeCode + ",可核对黑板 task.md);")
b.WriteString("\n2) 用 GET {BENCHMARK_BASE_URL}/openapi/v1/challenges 查看剩余未完成题目,一次性选定多道未完成题目补满并发;")
b.WriteString("\n3) 用 POST {BENCHMARK_BASE_URL}/openapi/v1/challenges/start?unique_code=<新题> 逐个启动新靶机,把在跑靶机数拉满到平台并发上限;")
b.WriteString("\n4) 对每道新题调用 run_coop 工具下发协作解题,不要只开一个靶机空等。")
b.WriteString("\n若所有题目已完成或平台任务已结束(接口持续 invalid_state),则停止并向用户汇报总分。")
}
return b.String()
}
func mergeToolCall(calls []ToolCall, delta ToolCallDelta) []ToolCall {
index := delta.Index
if index < 0 {
index = len(calls)
}
for len(calls) <= index {
calls = append(calls, ToolCall{Type: "function"})
}
call := &calls[index]
if delta.ID != "" {
call.ID = delta.ID
}
if delta.Name != "" {
call.Function.Name = delta.Name
}
call.Function.Arguments += delta.ArgumentsDelta
return calls
}
func estimateMessages(messages []Message) int {
total := 0
for _, message := range messages {
total += estimateMessageTokens(message)
}
return total
}
// estimateMessageTokens 估算单条消息的 token 数(含 tool_calls 与消息开销)。
func estimateMessageTokens(message Message) int {
total := 0
if message.Content != nil {
total += estimateTokens(*message.Content)
}
for _, call := range message.ToolCalls {
total += estimateTokens(call.Function.Name + call.Function.Arguments)
}
total += 8
return total
}
func estimateTokens(value string) int {
runes := []rune(value)
hanCount := 0
for _, r := range runes {
if unicode.Is(unicode.Han, r) {
hanCount++
}
}
other := len(runes) - hanCount
return int(float64(other)/4.0+float64(hanCount)*0.8) + 4
}
func stringPointer(value string) *string {
return &value
}