256 lines
7.6 KiB
Go
256 lines
7.6 KiB
Go
package utils
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
const ToolFeedbackContinuationHint = "Continuing the current task."
|
|
|
|
func FormatArgsJSON(args map[string]any, prettyPrint, disableEscapeHTML bool) string {
|
|
// Normalize nil to empty map for consistent output
|
|
if args == nil {
|
|
args = map[string]any{}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
if prettyPrint {
|
|
enc.SetIndent("", " ")
|
|
}
|
|
if disableEscapeHTML {
|
|
enc.SetEscapeHTML(false)
|
|
}
|
|
if err := enc.Encode(args); err != nil {
|
|
// Fallback to fmt.Sprintf to preserve visibility of problematic args
|
|
return fmt.Sprintf("%v", args)
|
|
}
|
|
return strings.TrimSpace(buf.String())
|
|
}
|
|
|
|
const ToolFeedbackStyleWorkingSummary = "working_summary"
|
|
|
|
// FormatToolFeedbackMessage renders a tool feedback message for chat channels.
|
|
// It keeps the tool name on the first line for animation and can include both
|
|
// a human explanation and the serialized tool arguments in the body.
|
|
func FormatToolFeedbackMessage(toolName, explanation, argsPreview string) string {
|
|
toolName = strings.TrimSpace(toolName)
|
|
explanation = strings.TrimSpace(explanation)
|
|
argsPreview = strings.TrimSpace(argsPreview)
|
|
|
|
bodyLines := make([]string, 0, 2)
|
|
if explanation != "" {
|
|
bodyLines = append(bodyLines, explanation)
|
|
}
|
|
if argsPreview != "" {
|
|
bodyLines = append(bodyLines, "```json\n"+argsPreview+"\n```")
|
|
}
|
|
body := strings.Join(bodyLines, "\n")
|
|
|
|
if toolName == "" {
|
|
return body
|
|
}
|
|
if body == "" {
|
|
return fmt.Sprintf("\U0001f527 `%s`", toolName)
|
|
}
|
|
|
|
return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, body)
|
|
}
|
|
|
|
// FormatToolFeedbackMessageWithStyle renders alternate tool feedback styles.
|
|
// The working_summary style intentionally omits explanations and raw arguments:
|
|
// progress UI should not leak internal paths, large JSON blobs, secrets, or
|
|
// model-drafted reasoning-like text.
|
|
func FormatToolFeedbackMessageWithStyle(style, toolName, explanation, argsPreview string) string {
|
|
if strings.EqualFold(strings.TrimSpace(style), ToolFeedbackStyleWorkingSummary) {
|
|
return FormatWorkingSummaryToolFeedbackMessage(toolName, argsPreview)
|
|
}
|
|
return FormatToolFeedbackMessage(toolName, explanation, argsPreview)
|
|
}
|
|
|
|
func FormatWorkingSummaryToolFeedbackMessage(toolName, argsPreview string) string {
|
|
toolName = strings.TrimSpace(toolName)
|
|
if toolName == "" {
|
|
return "Working..."
|
|
}
|
|
line := fmt.Sprintf("• tool: `%s`", sanitizeToolFeedbackCodeSpan(toolName))
|
|
if summary := summarizeToolFeedbackArgs(toolName, argsPreview); summary != "" {
|
|
line += fmt.Sprintf(" — `%s`", sanitizeToolFeedbackCodeSpan(summary))
|
|
}
|
|
return "Working...\n" + line
|
|
}
|
|
|
|
func sanitizeToolFeedbackCodeSpan(text string) string {
|
|
return strings.ReplaceAll(text, "`", "'")
|
|
}
|
|
|
|
func summarizeToolFeedbackArgs(toolName, argsPreview string) string {
|
|
argsPreview = strings.TrimSpace(argsPreview)
|
|
if argsPreview == "" {
|
|
return ""
|
|
}
|
|
|
|
var args map[string]any
|
|
if err := json.Unmarshal([]byte(argsPreview), &args); err != nil {
|
|
return ""
|
|
}
|
|
|
|
normalizedToolName := strings.ToLower(strings.TrimSpace(toolName))
|
|
if strings.Contains(normalizedToolName, "exec") {
|
|
if command := firstStringArg(args, "command"); command != "" {
|
|
return truncateToolFeedbackSummary(summarizeExecCommand(command))
|
|
}
|
|
return ""
|
|
}
|
|
if isFileToolFeedbackTool(normalizedToolName) {
|
|
if summary := firstStringArg(args, "path", "file_path", "filepath"); summary != "" {
|
|
return truncateToolFeedbackSummary(summarizeFilePath(summary))
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isFileToolFeedbackTool(toolName string) bool {
|
|
return strings.Contains(toolName, "read_file") ||
|
|
strings.Contains(toolName, "write_file") ||
|
|
strings.Contains(toolName, "edit_file") ||
|
|
strings.Contains(toolName, "append_file") ||
|
|
strings.Contains(toolName, "list_dir")
|
|
}
|
|
|
|
func firstStringArg(args map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value, ok := args[key]; ok {
|
|
if s, ok := value.(string); ok {
|
|
if normalized := normalizeToolFeedbackSummary(s); normalized != "" {
|
|
return normalized
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeToolFeedbackSummary(text string) string {
|
|
return redactToolFeedbackSecrets(strings.Join(strings.Fields(text), " "))
|
|
}
|
|
|
|
func summarizeFilePath(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
|
|
cleaned := filepath.Clean(path)
|
|
slashed := filepath.ToSlash(cleaned)
|
|
if idx := strings.LastIndex(slashed, "/workspace/"); idx >= 0 {
|
|
relative := strings.TrimPrefix(slashed[idx+len("/workspace/"):], "/")
|
|
if relative != "" && relative != "." {
|
|
return relative
|
|
}
|
|
}
|
|
|
|
if !filepath.IsAbs(cleaned) {
|
|
return slashed
|
|
}
|
|
return filepath.Base(cleaned)
|
|
}
|
|
|
|
func truncateToolFeedbackSummary(text string) string {
|
|
const maxRunes = 96
|
|
runes := []rune(text)
|
|
if len(runes) <= maxRunes {
|
|
return text
|
|
}
|
|
return strings.TrimSpace(string(runes[:maxRunes-3])) + "..."
|
|
}
|
|
|
|
func summarizeExecCommand(command string) string {
|
|
fields := strings.Fields(command)
|
|
for i := 0; i < len(fields); i++ {
|
|
token := strings.Trim(fields[i], `"'`)
|
|
if token == "" || isShellAssignment(token) {
|
|
continue
|
|
}
|
|
switch token {
|
|
case "env", "command", "time", "timeout", "sudo":
|
|
continue
|
|
case "bash", "sh", "zsh", "fish", "python", "python3", "node", "deno", "bun", "uv", "uvx", "npx":
|
|
for j := i + 1; j < len(fields); j++ {
|
|
next := strings.Trim(fields[j], `"'`)
|
|
if next == "" || strings.HasPrefix(next, "-") || isShellAssignment(next) {
|
|
continue
|
|
}
|
|
return filepath.Base(next)
|
|
}
|
|
return token
|
|
default:
|
|
return filepath.Base(token)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isShellAssignment(token string) bool {
|
|
return strings.Contains(token, "=") && !strings.HasPrefix(token, "/") && !strings.HasPrefix(token, ".")
|
|
}
|
|
|
|
var (
|
|
toolFeedbackSecretValuePatterns = []*regexp.Regexp{
|
|
regexp.MustCompile(`(?i)\bsk-(?:proj|or-v1)?-[A-Za-z0-9_-]{16,}`),
|
|
regexp.MustCompile(`\bAIza[A-Za-z0-9_-]{20,}`),
|
|
regexp.MustCompile(`\bmat_[A-Za-z0-9_-]{16,}`),
|
|
regexp.MustCompile(`\b\d{6,}:[A-Za-z0-9_-]{20,}`),
|
|
regexp.MustCompile(`\b(?:ghp|github_pat|glpat|xox[baprs])_[A-Za-z0-9_-]{16,}`),
|
|
}
|
|
toolFeedbackSecretKVPattern = regexp.MustCompile(`(?i)(\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|authorization)\b\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s&]+)`)
|
|
toolFeedbackSecretFlagPattern = regexp.MustCompile(`(?i)(--(?:api-key|access-token|auth-token|token|secret|password|authorization)(?:=|\s+))("[^"]*"|'[^']*'|[^\s&]+)`)
|
|
)
|
|
|
|
func redactToolFeedbackSecrets(text string) string {
|
|
if text == "" {
|
|
return ""
|
|
}
|
|
for _, pattern := range toolFeedbackSecretValuePatterns {
|
|
text = pattern.ReplaceAllString(text, "[redacted]")
|
|
}
|
|
text = toolFeedbackSecretKVPattern.ReplaceAllString(text, "${1}[redacted]")
|
|
text = toolFeedbackSecretFlagPattern.ReplaceAllString(text, "${1}[redacted]")
|
|
return text
|
|
}
|
|
|
|
// FitToolFeedbackMessage keeps tool feedback within a single outbound message.
|
|
// It preserves the first line when possible and truncates the explanation body
|
|
// instead of letting the message be split into multiple chunks.
|
|
func FitToolFeedbackMessage(content string, maxLen int) string {
|
|
content = strings.TrimSpace(content)
|
|
if content == "" || maxLen <= 0 {
|
|
return ""
|
|
}
|
|
if len([]rune(content)) <= maxLen {
|
|
return content
|
|
}
|
|
|
|
firstLine, rest, hasRest := strings.Cut(content, "\n")
|
|
firstLine = strings.TrimSpace(firstLine)
|
|
rest = strings.TrimSpace(rest)
|
|
|
|
if !hasRest || rest == "" {
|
|
return Truncate(firstLine, maxLen)
|
|
}
|
|
|
|
if len([]rune(firstLine)) >= maxLen {
|
|
return Truncate(firstLine, maxLen)
|
|
}
|
|
|
|
remaining := maxLen - len([]rune(firstLine)) - 1
|
|
if remaining <= 0 {
|
|
return Truncate(firstLine, maxLen)
|
|
}
|
|
|
|
return firstLine + "\n" + Truncate(rest, remaining)
|
|
}
|