f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
264 lines
7.5 KiB
Go
264 lines
7.5 KiB
Go
package sync
|
|
|
|
import (
|
|
"slices"
|
|
"time"
|
|
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/signals"
|
|
)
|
|
|
|
// computeSignalsFromMessages produces a SessionSignalUpdate from
|
|
// in-memory session metadata and messages. Pure function with no
|
|
// DB access — the caller already holds everything we need.
|
|
//
|
|
// Used by both the live write paths (writeBatch, writeSessionFull,
|
|
// writeIncremental) and the legacy backfill path (RecomputeSignals,
|
|
// which reads msgs from the DB once and then calls this).
|
|
func computeSignalsFromMessages(
|
|
sess db.Session, msgs []db.Message,
|
|
) db.SessionSignalUpdate {
|
|
toolRows := extractToolCallRows(msgs)
|
|
heuristics := signals.AnalyzeHeuristics(signals.HeuristicInput{
|
|
Messages: extractHeuristicMessages(msgs),
|
|
ToolRows: toolRows,
|
|
})
|
|
ctxTokens := extractContextTokens(msgs)
|
|
boundaries := extractCompactBoundaryOrdinals(msgs)
|
|
model := extractMostCommonModel(msgs)
|
|
lastRole, lastContent := extractLastMessageRole(msgs)
|
|
|
|
toolHealth := signals.ComputeToolHealth(toolRows)
|
|
ctxPressure := signals.ComputeContextPressure(
|
|
ctxTokens, sess.PeakContextTokens, model,
|
|
)
|
|
|
|
// Prefer explicit boundary count when available; fall back
|
|
// to the token-drop heuristic for sessions without
|
|
// compact-boundary messages.
|
|
compactionCount := ctxPressure.CompactionCount
|
|
if len(boundaries) > 0 {
|
|
compactionCount = len(boundaries)
|
|
}
|
|
|
|
midTaskCalls := make(
|
|
[]signals.ToolCallOrdinal, 0, len(toolRows),
|
|
)
|
|
for _, t := range toolRows {
|
|
midTaskCalls = append(midTaskCalls,
|
|
signals.ToolCallOrdinal{
|
|
MessageOrdinal: t.MessageOrdinal,
|
|
ToolName: t.ToolName,
|
|
})
|
|
}
|
|
midTaskCount := signals.CountMidTaskCompactions(
|
|
boundaries, midTaskCalls,
|
|
)
|
|
|
|
finalStreak := computeFinalStreak(toolRows)
|
|
|
|
var lastActivity time.Time
|
|
if sess.EndedAt != nil {
|
|
lastActivity, _ = time.Parse(
|
|
time.RFC3339Nano, *sess.EndedAt,
|
|
)
|
|
}
|
|
|
|
outcomeResult := signals.ClassifyOutcome(signals.OutcomeInput{
|
|
IsAutomated: sess.IsAutomated,
|
|
MessageCount: sess.MessageCount,
|
|
EndedWithRole: lastRole,
|
|
FinalFailureStreak: finalStreak,
|
|
LastAssistantText: lastContent,
|
|
LastActivity: lastActivity,
|
|
})
|
|
|
|
hasContextData := sess.HasPeakContextTokens
|
|
if !hasContextData {
|
|
for _, t := range ctxTokens {
|
|
if t.HasContextTokens {
|
|
hasContextData = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
scoreResult := signals.ComputeHealthScore(signals.ScoreInput{
|
|
Outcome: outcomeResult.Outcome,
|
|
OutcomeConfidence: outcomeResult.Confidence,
|
|
HasToolCalls: len(toolRows) > 0,
|
|
FailureSignalCount: toolHealth.FailureSignalCount,
|
|
RetryCount: toolHealth.RetryCount,
|
|
EditChurnCount: toolHealth.EditChurnCount,
|
|
ConsecutiveFailMax: toolHealth.ConsecutiveFailureMax,
|
|
HasContextData: hasContextData,
|
|
CompactionCount: compactionCount,
|
|
MidTaskCompactionCount: midTaskCount,
|
|
PressureMax: ctxPressure.PressureMax,
|
|
Heuristics: heuristics,
|
|
})
|
|
|
|
var pendingSince *string
|
|
if outcomeResult.IsRecent {
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
pendingSince = &now
|
|
}
|
|
|
|
var healthGrade *string
|
|
if scoreResult.Grade != "" {
|
|
healthGrade = &scoreResult.Grade
|
|
}
|
|
|
|
return db.SessionSignalUpdate{
|
|
ToolFailureSignalCount: toolHealth.FailureSignalCount,
|
|
ToolRetryCount: toolHealth.RetryCount,
|
|
EditChurnCount: toolHealth.EditChurnCount,
|
|
ConsecutiveFailureMax: toolHealth.ConsecutiveFailureMax,
|
|
Outcome: outcomeResult.Outcome,
|
|
OutcomeConfidence: outcomeResult.Confidence,
|
|
EndedWithRole: lastRole,
|
|
FinalFailureStreak: finalStreak,
|
|
SignalsPendingSince: pendingSince,
|
|
CompactionCount: compactionCount,
|
|
MidTaskCompactionCount: midTaskCount,
|
|
ContextPressureMax: ctxPressure.PressureMax,
|
|
HealthScore: scoreResult.Score,
|
|
HealthGrade: healthGrade,
|
|
HasToolCalls: len(toolRows) > 0,
|
|
HasContextData: hasContextData,
|
|
QualitySignals: db.QualitySignals{
|
|
Version: db.CurrentQualitySignalVersion,
|
|
ShortPromptCount: heuristics.ShortPromptCount,
|
|
UnstructuredStart: heuristics.UnstructuredStart,
|
|
MissingSuccessCriteriaCount: heuristics.
|
|
MissingSuccessCriteriaCount,
|
|
MissingVerificationCount: heuristics.
|
|
MissingVerificationCount,
|
|
DuplicatePromptCount: heuristics.DuplicatePromptCount,
|
|
NoCodeContextCount: heuristics.NoCodeContextCount,
|
|
RunawayToolLoopCount: heuristics.RunawayToolLoopCount,
|
|
},
|
|
}
|
|
}
|
|
|
|
func extractHeuristicMessages(
|
|
msgs []db.Message,
|
|
) []signals.HeuristicMessage {
|
|
rows := make([]signals.HeuristicMessage, 0, len(msgs))
|
|
for _, m := range msgs {
|
|
rows = append(rows, signals.HeuristicMessage{
|
|
Role: m.Role,
|
|
Content: m.Content,
|
|
IsSystem: m.IsSystem,
|
|
Ordinal: m.Ordinal,
|
|
Timestamp: m.Timestamp,
|
|
})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// extractToolCallRows builds signal inputs from in-memory tool
|
|
// calls. CallIndex is the call's position within its message.
|
|
// EventStatus is the status of the latest result event (events
|
|
// are stored in event_index order, so the last one wins).
|
|
func extractToolCallRows(
|
|
msgs []db.Message,
|
|
) []signals.ToolCallRow {
|
|
rows := make([]signals.ToolCallRow, 0)
|
|
for _, m := range msgs {
|
|
for callIdx, tc := range m.ToolCalls {
|
|
status := ""
|
|
if n := len(tc.ResultEvents); n > 0 {
|
|
status = tc.ResultEvents[n-1].Status
|
|
}
|
|
rows = append(rows, signals.ToolCallRow{
|
|
ToolName: tc.ToolName,
|
|
Category: tc.Category,
|
|
InputJSON: tc.InputJSON,
|
|
ResultContent: tc.ResultContent,
|
|
MessageOrdinal: m.Ordinal,
|
|
CallIndex: callIdx,
|
|
EventStatus: status,
|
|
})
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// extractContextTokens returns context-token measurements for
|
|
// assistant messages in order.
|
|
func extractContextTokens(
|
|
msgs []db.Message,
|
|
) []signals.ContextTokenRow {
|
|
var rows []signals.ContextTokenRow
|
|
for _, m := range msgs {
|
|
if m.Role != "assistant" {
|
|
continue
|
|
}
|
|
rows = append(rows, signals.ContextTokenRow{
|
|
ContextTokens: m.ContextTokens,
|
|
HasContextTokens: m.HasContextTokens,
|
|
})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// extractCompactBoundaryOrdinals returns ordinals of explicit
|
|
// compact-boundary messages in ascending order.
|
|
func extractCompactBoundaryOrdinals(msgs []db.Message) []int {
|
|
var ords []int
|
|
for _, m := range msgs {
|
|
if m.IsCompactBoundary {
|
|
ords = append(ords, m.Ordinal)
|
|
}
|
|
}
|
|
return ords
|
|
}
|
|
|
|
// extractMostCommonModel returns the assistant model name that
|
|
// appears most often. Ties are broken by the model that appears
|
|
// first chronologically — matches SQLite's GROUP BY iteration
|
|
// order closely enough that legacy and live computations agree
|
|
// on real sessions (every observed session has a clear majority
|
|
// model).
|
|
func extractMostCommonModel(msgs []db.Message) string {
|
|
counts := map[string]int{}
|
|
firstSeen := map[string]int{}
|
|
for i, m := range msgs {
|
|
if m.Role != "assistant" || m.Model == "" {
|
|
continue
|
|
}
|
|
counts[m.Model]++
|
|
if _, ok := firstSeen[m.Model]; !ok {
|
|
firstSeen[m.Model] = i
|
|
}
|
|
}
|
|
var best string
|
|
bestCount := -1
|
|
for model, n := range counts {
|
|
switch {
|
|
case n > bestCount:
|
|
best, bestCount = model, n
|
|
case n == bestCount && firstSeen[model] < firstSeen[best]:
|
|
best = model
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// extractLastMessageRole returns the role and content of the
|
|
// last non-system message. Empty strings if none.
|
|
func extractLastMessageRole(
|
|
msgs []db.Message,
|
|
) (role, content string) {
|
|
if msgs == nil {
|
|
return "", ""
|
|
}
|
|
for _, v := range slices.Backward(msgs) {
|
|
if !v.IsSystem {
|
|
return v.Role, v.Content
|
|
}
|
|
}
|
|
return "", ""
|
|
}
|