Skip to content

Commit 5020b48

Browse files
committed
chore: refine error diagnostics
1 parent 8b07887 commit 5020b48

File tree

17 files changed

+309
-58
lines changed

17 files changed

+309
-58
lines changed

internal/agent/runtime_metrics.go

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
package agent
22

3-
import "time"
3+
import (
4+
"strings"
5+
"time"
6+
7+
"github.com/voocel/codebot/internal/apperr"
8+
)
9+
10+
type ErrorSnapshot struct {
11+
Kind apperr.Kind
12+
Message string
13+
Detail string
14+
Timestamp time.Time
15+
}
416

517
type RuntimeMetricsSnapshot struct {
618
ReminderTotal int
@@ -10,6 +22,8 @@ type RuntimeMetricsSnapshot struct {
1022
CompactionSaved int
1123
CompactionByKind map[CompactionKind]int
1224
CompactionSavedByKind map[CompactionKind]int
25+
ErrorTotal int
26+
ErrorByKind map[apperr.Kind]int
1327
}
1428

1529
type runtimeMetrics struct {
@@ -20,13 +34,16 @@ type runtimeMetrics struct {
2034
compactionSaved int
2135
compactionByKind map[CompactionKind]int
2236
compactionSavedByKind map[CompactionKind]int
37+
errorTotal int
38+
errorByKind map[apperr.Kind]int
2339
}
2440

2541
func newRuntimeMetrics() *runtimeMetrics {
2642
return &runtimeMetrics{
2743
reminderByKind: make(map[RuntimeReminderKind]int),
2844
compactionByKind: make(map[CompactionKind]int),
2945
compactionSavedByKind: make(map[CompactionKind]int),
46+
errorByKind: make(map[apperr.Kind]int),
3047
}
3148
}
3249

@@ -46,6 +63,10 @@ func (s *Session) RuntimeMetrics() RuntimeMetricsSnapshot {
4663
for k, v := range s.metrics.compactionSavedByKind {
4764
compactionSavedByKind[k] = v
4865
}
66+
errorByKind := make(map[apperr.Kind]int, len(s.metrics.errorByKind))
67+
for k, v := range s.metrics.errorByKind {
68+
errorByKind[k] = v
69+
}
4970
return RuntimeMetricsSnapshot{
5071
ReminderTotal: s.metrics.reminderTotal,
5172
ReminderByKind: reminderByKind,
@@ -54,6 +75,8 @@ func (s *Session) RuntimeMetrics() RuntimeMetricsSnapshot {
5475
CompactionSaved: s.metrics.compactionSaved,
5576
CompactionByKind: compactionByKind,
5677
CompactionSavedByKind: compactionSavedByKind,
78+
ErrorTotal: s.metrics.errorTotal,
79+
ErrorByKind: errorByKind,
5780
}
5881
}
5982

@@ -119,3 +142,57 @@ func (s *Session) recordCompactionSnapshot(kind CompactionKind, strategy, reason
119142
Timestamp: time.Now(),
120143
}
121144
}
145+
146+
func (s *Session) recordErrorDiagnostic(err error) {
147+
if err == nil {
148+
return
149+
}
150+
151+
snapshot := buildErrorSnapshot(err)
152+
153+
s.mu.Lock()
154+
defer s.mu.Unlock()
155+
156+
if s.metrics == nil {
157+
s.metrics = newRuntimeMetrics()
158+
}
159+
s.metrics.errorTotal++
160+
s.metrics.errorByKind[snapshot.Kind]++
161+
162+
s.recentErrors = append(s.recentErrors, snapshot)
163+
if len(s.recentErrors) > maxRecentErrors {
164+
s.recentErrors = append([]ErrorSnapshot(nil), s.recentErrors[len(s.recentErrors)-maxRecentErrors:]...)
165+
}
166+
}
167+
168+
func buildErrorSnapshot(err error) ErrorSnapshot {
169+
message := strings.TrimSpace(apperr.Format(err, ""))
170+
if message == "" {
171+
message = "error"
172+
}
173+
detail := strings.TrimSpace(err.Error())
174+
if detail == message {
175+
detail = ""
176+
} else if strings.HasPrefix(detail, message+": ") {
177+
detail = strings.TrimSpace(strings.TrimPrefix(detail, message+": "))
178+
}
179+
return ErrorSnapshot{
180+
Kind: apperr.KindOf(err),
181+
Message: message,
182+
Detail: detail,
183+
Timestamp: time.Now(),
184+
}
185+
}
186+
187+
func (s *Session) RecentErrors(limit int) []ErrorSnapshot {
188+
s.mu.Lock()
189+
defer s.mu.Unlock()
190+
191+
if limit <= 0 || limit > len(s.recentErrors) {
192+
limit = len(s.recentErrors)
193+
}
194+
start := len(s.recentErrors) - limit
195+
snapshots := make([]ErrorSnapshot, limit)
196+
copy(snapshots, s.recentErrors[start:])
197+
return snapshots
198+
}

internal/agent/runtime_policy.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
const (
1717
maxRecentToolCalls = 8
18+
maxRecentErrors = 10
1819
repeatedToolCallThreshold = 4
1920
)
2021

internal/agent/session.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ type Session struct {
115115
lastRunSummary *agentcore.RunSummary
116116
lastReminder *ReminderSnapshot
117117
lastCompaction *CompactionSnapshot
118+
recentErrors []ErrorSnapshot
118119
dirtySeq uint64 // incremented each time a repo-mutating tool succeeds; hook goroutine captures this and only clears if unchanged
119120
generation uint64 // incremented on session switch; async goroutines check this to avoid cross-session callbacks
120121

@@ -235,6 +236,7 @@ func (s *Session) resetHarnessStateLocked() {
235236
s.lastRunSummary = nil
236237
s.lastReminder = nil
237238
s.lastCompaction = nil
239+
s.recentErrors = nil
238240
s.dirtySeq = 0
239241
s.metrics = newRuntimeMetrics()
240242
s.skillRuntime = skillRuntimeState{}

internal/agent/session_state.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ func (s *Session) handleAgentEvent(ev agentcore.Event) {
9393
}
9494

9595
func (s *Session) emit(ev SessionEvent) {
96+
switch ev.Type {
97+
case SEError:
98+
s.recordErrorDiagnostic(ev.Error)
99+
case SEAgentEvent:
100+
if ev.AgentEvent != nil && ev.AgentEvent.Type == agentcore.EventError {
101+
s.recordErrorDiagnostic(ev.AgentEvent.Err)
102+
}
103+
}
104+
96105
s.mu.Lock()
97106
listeners := make([]func(SessionEvent), len(s.listeners))
98107
copy(listeners, s.listeners)

internal/apperr/errors.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
11
package apperr
22

33
import (
4+
"context"
45
"errors"
56
"fmt"
67
)
78

9+
type Kind string
10+
11+
const (
12+
KindUnknown Kind = ""
13+
KindCanceled Kind = "canceled"
14+
KindConfig Kind = "config"
15+
KindPermission Kind = "permission"
16+
KindProvider Kind = "provider"
17+
KindSession Kind = "session"
18+
KindToolInput Kind = "tool_input"
19+
KindToolExec Kind = "tool_exec"
20+
)
21+
822
// UserError exposes a concise user-facing message while preserving the full
923
// wrapped error for logging and debugging.
1024
type UserError interface {
@@ -14,6 +28,7 @@ type UserError interface {
1428

1529
type Error struct {
1630
Display string
31+
Kind Kind
1732
Err error
1833
}
1934

@@ -41,18 +56,58 @@ func (e *Error) DisplayMessage() string {
4156
return e.Display
4257
}
4358

59+
func (e *Error) ErrorKind() Kind {
60+
if e == nil {
61+
return KindUnknown
62+
}
63+
return e.Kind
64+
}
65+
4466
func New(display string) error {
4567
return &Error{Display: display}
4668
}
4769

70+
func NewKind(kind Kind, display string) error {
71+
return &Error{Display: display, Kind: kind}
72+
}
73+
4874
func Newf(format string, args ...any) error {
4975
return &Error{Display: fmt.Sprintf(format, args...)}
5076
}
5177

78+
func NewKindf(kind Kind, format string, args ...any) error {
79+
return &Error{Display: fmt.Sprintf(format, args...), Kind: kind}
80+
}
81+
5282
func Wrap(display string, err error) error {
5383
return &Error{Display: display, Err: err}
5484
}
5585

86+
func WrapKind(kind Kind, display string, err error) error {
87+
return &Error{Display: display, Kind: kind, Err: err}
88+
}
89+
90+
func KindOf(err error) Kind {
91+
original := err
92+
for err != nil {
93+
type kindCarrier interface {
94+
ErrorKind() Kind
95+
}
96+
if carrier, ok := err.(kindCarrier); ok && carrier.ErrorKind() != KindUnknown {
97+
return carrier.ErrorKind()
98+
}
99+
err = errors.Unwrap(err)
100+
}
101+
if errors.Is(original, context.Canceled) {
102+
return KindCanceled
103+
}
104+
return KindUnknown
105+
}
106+
107+
func IsKind(err error, kind Kind) bool {
108+
return KindOf(err) == kind
109+
}
110+
56111
func Format(err error, fallbackPrefix string) string {
57112
if err == nil {
58113
return ""

internal/bootstrap/assemble_session.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/voocel/agentcore"
99
agentcoretools "github.com/voocel/agentcore/tools"
10+
"github.com/voocel/codebot/internal/apperr"
1011
"github.com/voocel/codebot/internal/approval"
1112
"github.com/voocel/codebot/internal/config"
1213
"github.com/voocel/codebot/internal/hooks"
@@ -84,7 +85,7 @@ func resolveActiveModel(input *resolvedInput) (config.Resolved, string, agentcor
8485
}
8586
chatModel, err := input.modelFactory(provType, activeModel, activeAPIKey, activeBaseURL)
8687
if err != nil {
87-
return config.Resolved{}, "", nil, fmt.Errorf("create model: %w", err)
88+
return config.Resolved{}, "", nil, apperr.WrapKind(apperr.KindProvider, "create model failed", err)
8889
}
8990

9091
settings := input.settings

internal/bootstrap/input.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,15 @@ func resolveInput(opts Options) (*resolvedInput, error) {
6666
sessionManager := storage.NewManager(config.SessionsDir(cwd))
6767
sessionStore, err := resolveSession(sessionManager, cwd, opts.Continue, opts.Resume, opts.NonTTYMode)
6868
if err != nil {
69-
return nil, fmt.Errorf("session: %w", err)
69+
return nil, apperr.WrapKind(apperr.KindSession, "session setup failed", err)
7070
}
7171

7272
var sessionSnapshot storage.ContextSnapshot
7373
if opts.Continue || opts.Resume {
7474
sessionSnapshot, err = sessionStore.BuildSnapshot()
7575
if err != nil {
7676
_ = sessionStore.Close()
77-
return nil, fmt.Errorf("restore context: %w", err)
77+
return nil, apperr.WrapKind(apperr.KindSession, "restore context failed", err)
7878
}
7979
}
8080

@@ -98,7 +98,7 @@ func ensureProviderSetup(cwd string, settings config.Resolved, nonTTY bool) (con
9898
if hasConfiguredProviderCredentials(settings, settings.Provider) {
9999
return settings, "", nil
100100
}
101-
return settings, "", apperr.Newf("configuration error: settings.provider=%q is missing or not configured in settings.json", settings.Provider)
101+
return settings, "", apperr.NewKindf(apperr.KindConfig, "configuration error: settings.provider=%q is missing or not configured in settings.json", settings.Provider)
102102
}
103103

104104
apiKey, _ := settings.ProviderCredentials(settings.Provider)
@@ -114,12 +114,12 @@ func ensureProviderSetup(cwd string, settings config.Resolved, nonTTY bool) (con
114114
}
115115

116116
if nonTTY {
117-
return settings, "", fmt.Errorf("api key not set; set %s or configure providers in %s",
117+
return settings, "", apperr.NewKindf(apperr.KindConfig, "api key not set; set %s or configure providers in %s",
118118
config.ProviderEnvKey(settings.Provider), filepath.Join(config.UserConfigDir(), "settings.json"))
119119
}
120120

121121
if err := config.RunSetup(settings); err != nil {
122-
return settings, "", fmt.Errorf("setup: %w", err)
122+
return settings, "", apperr.WrapKind(apperr.KindConfig, "setup failed", err)
123123
}
124124
resolved, err := config.ResolveAllStrict(cwd)
125125
if err != nil {
@@ -160,7 +160,7 @@ func resolveSession(mgr *storage.Manager, cwd string, cont, resume, nonTTY bool)
160160
return mgr.Create(cwd)
161161
}
162162
if nonTTY {
163-
return nil, fmt.Errorf("-r requires interactive terminal, use -c or -session in non-interactive mode")
163+
return nil, apperr.NewKind(apperr.KindSession, "-r requires interactive terminal, use -c or -session in non-interactive mode")
164164
}
165165

166166
fmt.Fprintf(os.Stderr, "Available sessions:\n")
@@ -177,11 +177,11 @@ func resolveSession(mgr *storage.Manager, cwd string, cont, resume, nonTTY bool)
177177
reader := bufio.NewReader(os.Stdin)
178178
raw, err := reader.ReadString('\n')
179179
if err != nil && !errors.Is(err, io.EOF) {
180-
return nil, fmt.Errorf("read session selection: %w", err)
180+
return nil, apperr.WrapKind(apperr.KindSession, "read session selection failed", err)
181181
}
182182
choice := strings.TrimSpace(raw)
183183
if choice == "" {
184-
return nil, fmt.Errorf("no session selected")
184+
return nil, apperr.NewKind(apperr.KindSession, "no session selected")
185185
}
186186

187187
for _, s := range sessions {
@@ -192,11 +192,11 @@ func resolveSession(mgr *storage.Manager, cwd string, cont, resume, nonTTY bool)
192192

193193
if idx, convErr := strconv.Atoi(choice); convErr == nil {
194194
if idx < 1 || idx > len(sessions) {
195-
return nil, fmt.Errorf("invalid session index %d (range: 1-%d)", idx, len(sessions))
195+
return nil, apperr.NewKindf(apperr.KindSession, "invalid session index %d (range: 1-%d)", idx, len(sessions))
196196
}
197197
return mgr.OpenPath(sessions[idx-1].Path)
198198
}
199-
return nil, fmt.Errorf("invalid session selection %q", choice)
199+
return nil, apperr.NewKindf(apperr.KindSession, "invalid session selection %q", choice)
200200
default:
201201
return mgr.Create(cwd)
202202
}

internal/config/settings.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,12 @@ func ResolveProviderType(name, explicitType string) (string, error) {
4747
if provider.IsSupportedType(provType) {
4848
return provType, nil
4949
}
50-
return "", apperr.Newf("configuration error: providers.%s.type=%q is unsupported", name, explicitType)
50+
return "", apperr.NewKindf(apperr.KindConfig, "configuration error: providers.%s.type=%q is unsupported", name, explicitType)
5151
}
5252
if t, ok := KnownProviderTypes[name]; ok {
5353
return t, nil
5454
}
55-
return "", apperr.Newf("configuration error: providers.%s.type is required for custom providers", name)
55+
return "", apperr.NewKindf(apperr.KindConfig, "configuration error: providers.%s.type is required for custom providers", name)
5656
}
5757

5858
// ResolveConfiguredProviderType resolves the protocol type for a configured provider.
@@ -502,7 +502,7 @@ func loadSettingsFileStrict(path string) (Settings, error) {
502502
return s, err
503503
}
504504
if err := json.Unmarshal(data, &s); err != nil {
505-
return s, apperr.Wrap("configuration error: malformed settings.json", fmt.Errorf("%s: %w", path, err))
505+
return s, apperr.WrapKind(apperr.KindConfig, "configuration error: malformed settings.json", fmt.Errorf("%s: %w", path, err))
506506
}
507507
return s, nil
508508
}

internal/provider/provider.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
package provider
22

33
import (
4-
"fmt"
54
"slices"
65
"strings"
76

87
"github.com/voocel/agentcore"
98
"github.com/voocel/agentcore/llm"
9+
"github.com/voocel/codebot/internal/apperr"
1010
)
1111

1212
var supportedTypeNames = []string{"openai", "anthropic", "gemini", "openrouter"}
@@ -65,7 +65,7 @@ func CreateModel(prov, name, apiKey, baseURL string) (agentcore.ChatModel, error
6565
func newProviderModel(prov, name, apiKey, baseURL string) (agentcore.ChatModel, error) {
6666
factory, ok := modelFactories[prov]
6767
if !ok {
68-
return nil, fmt.Errorf("unsupported provider type %q", prov)
68+
return nil, apperr.NewKindf(apperr.KindProvider, "unsupported provider type %q", prov)
6969
}
7070
return factory(name, apiKey, baseURL)
7171
}

0 commit comments

Comments
 (0)