Files
Agent-System/internal/stream/classify.go
T
weichenandCursor f6813cc272 feat(api): add additive HTTP/SSE error envelopes
Public errors now emit code, safe message, and request_id while keeping the legacy error field. Raw SQL, paths, tokens, and XSS payloads are stripped; the frontend branches on code instead of substring matching.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 14:40:56 +00:00

126 lines
2.8 KiB
Go

package stream
import (
"errors"
"io"
"net"
"strings"
"unicode/utf8"
)
// ClassifyError maps a raw error into structured ErrorInfo using heuristic rules.
func ClassifyError(err error, phaseID, requestID, sessionID, workspaceID string) ErrorInfo {
if err == nil {
err = errors.New("unknown error")
}
msg := strings.ToLower(err.Error())
info := ErrorInfo{
PhaseID: phaseID,
RequestID: requestID,
SessionID: sessionID,
WorkspaceID: workspaceID,
}
raw := err.Error()
if !containsInternalLeak(raw) {
info.Upstream = truncateRunes(raw, 80)
}
switch {
case strings.Contains(msg, "编辑中") || strings.Contains(msg, "禁止并行修改") || strings.Contains(msg, "edit lease"):
info.Code = "DOC_BUSY"
info.Class = ErrorClassFatal
info.UserMessage = "该文档正在由项目助手或其他用户编辑中;当前请求未执行,请等待现有任务完成"
info.Actions = []string{}
case isTimeoutError(msg):
info.Code = "CHAT_TIMEOUT"
info.Class = ErrorClassTransient
info.UserMessage = "生成回复超时,请稍后重试"
info.Actions = []string{"retry"}
case isNetworkError(err, msg):
info.Code = "NETWORK_ERROR"
info.Class = ErrorClassTransient
info.UserMessage = "网络连接异常,请稍后重试"
info.Actions = []string{"retry"}
case workspaceID != "":
info.Code = "TASK_INTERRUPTED"
info.Class = ErrorClassResumable
info.UserMessage = "任务执行中断,可继续或重新开始"
info.Actions = []string{"continue", "restart"}
default:
info.Code = "INTERNAL_ERROR"
info.Class = ErrorClassFatal
info.UserMessage = "系统内部错误,请重新开始会话"
info.Actions = []string{"restart"}
}
info.Message = info.UserMessage
return info
}
func containsInternalLeak(s string) bool {
low := strings.ToLower(s)
for _, n := range []string{"sql", "pq:", "token", "password", "/home/", "prompt"} {
if strings.Contains(low, n) || strings.Contains(s, n) {
return true
}
}
return false
}
func isTimeoutError(msg string) bool {
return strings.Contains(msg, "timeout") ||
strings.Contains(msg, "deadline") ||
strings.Contains(msg, "timed out")
}
func isNetworkError(err error, msg string) bool {
var netErr net.Error
if errors.As(err, &netErr) {
if netErr.Timeout() || netErr.Temporary() {
return true
}
}
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Op == "dial" {
return true
}
}
networkHints := []string{
"connection refused",
"eof",
"broken pipe",
}
for _, hint := range networkHints {
if strings.Contains(msg, hint) {
return true
}
}
if errors.Is(err, io.EOF) {
return true
}
return false
}
func truncateRunes(s string, maxRunes int) string {
if maxRunes <= 0 || s == "" {
return s
}
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
runes := []rune(s)
return string(runes[:maxRunes])
}