package permission // 对话历史投影 -- 为分类器构建精简的上下文. // // 分类器不需要看到完整的对话历史,只需要一个投影: // 用户说了什么 + Agent 调用了什么工具. // 这大幅减少分类器的输入 token,同时保留安全判断所需的上下文. // // 精妙之处(CLEVER): 分类器看到的不是完整对话,而是投影: // - 只保留用户文本和助手的 tool_use // - 排除助手文本回复(防止模型自己构造文本影响分类器) // - 每个 tool_use 压缩为一行 // // 这大幅减少分类器的输入 token,同时保留安全判断所需的上下文. import ( "fmt" "strings" "git.flytoex.net/yuanwei/flyto-agent/pkg/query" ) // BuildTranscript 从对话消息中构建分类器的对话历史投影. // // 投影规则: // - 用户消息:保留文本内容(截断到 200 字符) // - 助手消息:只保留 tool_use 调用(压缩为一行) // - 系统消息:跳过 // - 最多保留最近 maxEntries 条(0 表示默认 20 条) // // 升华改进(ELEVATED): 排除助手的文本回复,只保留工具调用. // 这是一个安全决策:如果保留助手文本,攻击者可以通过 prompt injection // 让助手在文本回复中写入"用户已授权此操作"来影响分类器. // 只保留 tool_use 记录是客观事实,无法被注入操纵. // 替代方案:保留完整对话(包含助手文本回复,可能被 prompt injection 利用). func BuildTranscript(messages []query.Message, maxEntries int) []TranscriptEntry { if maxEntries <= 0 { maxEntries = 20 } var entries []TranscriptEntry for _, msg := range messages { switch msg.Role { case query.RoleUser: text := extractUserText(msg.Content) if text != "" { entries = append(entries, TranscriptEntry{ Role: "user", Content: truncateTranscript(text, 200), }) } case query.RoleAssistant: // 只提取 tool_use,忽略文本回复 for _, block := range msg.Content { if block.Type == query.ContentToolUse { compact := CompactToolUse(block.Name, block.Input) entries = append(entries, TranscriptEntry{ Role: "assistant", Content: compact, }) } } default: // 跳过系统消息和其他类型 continue } } // 只保留最近的 maxEntries 条 if len(entries) > maxEntries { entries = entries[len(entries)-maxEntries:] } return entries } // CompactToolUse 将工具调用压缩为一行. // // 格式:ToolName: <关键参数> // 例如: // - Bash: ls -la /home // - Edit: /src/main.go (old_string -> new_string) // - Read: /src/config.go // - Grep: pattern="TODO" path=/src func CompactToolUse(toolName string, input map[string]any) string { switch toolName { case "Bash": cmd, _ := input["command"].(string) if len(cmd) > 100 { cmd = cmd[:100] + "..." } return fmt.Sprintf("Bash: %s", cmd) case "Edit", "FileEdit": fp, _ := input["file_path"].(string) oldStr, _ := input["old_string"].(string) if len(oldStr) > 30 { oldStr = oldStr[:30] + "..." } return fmt.Sprintf("Edit: %s (replacing: %q)", fp, oldStr) case "Write", "FileWrite": fp, _ := input["file_path"].(string) content, _ := input["content"].(string) return fmt.Sprintf("Write: %s (%d bytes)", fp, len(content)) case "Read", "FileRead": fp, _ := input["file_path"].(string) return fmt.Sprintf("Read: %s", fp) case "Grep": pattern, _ := input["pattern"].(string) path, _ := input["path"].(string) return fmt.Sprintf("Grep: pattern=%q path=%s", pattern, path) case "Glob": pattern, _ := input["pattern"].(string) return fmt.Sprintf("Glob: %s", pattern) case "WebFetch": u, _ := input["url"].(string) return fmt.Sprintf("WebFetch: %s", u) default: // 通用格式:列出所有键 keys := make([]string, 0, len(input)) for k := range input { keys = append(keys, k) } return fmt.Sprintf("%s: {%s}", toolName, strings.Join(keys, ", ")) } } // extractUserText 从消息内容块中提取用户文本. func extractUserText(content []query.Content) string { var parts []string for _, block := range content { if block.Type == query.ContentText && block.Text != "" { parts = append(parts, block.Text) } } return strings.Join(parts, " ") } // truncateTranscript 截断文本到指定字符数. func truncateTranscript(s string, maxLen int) string { if len(s) <= maxLen { return s } if maxLen < 4 { return s[:maxLen] } return s[:maxLen-3] + "..." }