first commit
This commit is contained in:
@@ -0,0 +1,684 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user