picoclaw/pkg/utils/tool_feedback.go
2026-05-06 18:55:37 -07:00

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)
}