204 lines
6.4 KiB
Go
204 lines
6.4 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Skill usage telemetry. Every Agent tool call already lands in the durable
|
|
// journal, so attribution needs no new table and no write path: we only read
|
|
// back what the runs already recorded. This answers the question the skill
|
|
// ecosystem otherwise cannot: after a skill is installed, is it ever used?
|
|
|
|
const (
|
|
// A run window keeps the aggregation bounded; older runs stay in the journal
|
|
// but are not re-scanned on every request.
|
|
cloudAgentSkillUsageRunWindow = 200
|
|
// Card paths are unbounded per pack, so the response only carries the hottest.
|
|
cloudAgentSkillUsageTopCards = 5
|
|
)
|
|
|
|
// CloudAgentSkillUsage aggregates skill adoption for one user.
|
|
type CloudAgentSkillUsage struct {
|
|
ScannedRuns int `json:"scannedRuns"`
|
|
// SearchCalls counts retrieval rounds, which are not attributable to a single
|
|
// skill: one call ranks every enabled skill at once.
|
|
SearchCalls int `json:"searchCalls"`
|
|
Skills []CloudAgentSkillUsageEntry `json:"skills"`
|
|
}
|
|
|
|
// CloudAgentSkillUsageEntry is one skill's adoption across the scanned runs.
|
|
type CloudAgentSkillUsageEntry struct {
|
|
SkillID string `json:"skillId"`
|
|
SkillName string `json:"skillName,omitempty"`
|
|
// RunsEnabled counts runs that loaded this skill.
|
|
RunsEnabled int `json:"runsEnabled"`
|
|
// SearchHits counts rounds in which this skill was returned as a match.
|
|
SearchHits int `json:"searchHits"`
|
|
ReadCalls int `json:"readCalls"`
|
|
// ReadFailures separates rejected reads (bad path, wrong skill) from reads
|
|
// that actually returned content.
|
|
ReadFailures int `json:"readFailures"`
|
|
// EntryReads counts SKILL.md / directory listings; CardReads counts cards.
|
|
// The ratio is the cost signal: entry reads mean the pack outline is still
|
|
// being paid for even when only one card was wanted.
|
|
EntryReads int `json:"entryReads"`
|
|
CardReads int `json:"cardReads"`
|
|
TopCards []CloudAgentSkillCardUsage `json:"topCards,omitempty"`
|
|
}
|
|
|
|
// CloudAgentSkillCardUsage is one card path and how often it was read.
|
|
type CloudAgentSkillCardUsage struct {
|
|
Path string `json:"path"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// CloudAgentSkillUsage reports how the caller's skills were actually used.
|
|
func (s *Service) CloudAgentSkillUsage(userID string) (*CloudAgentSkillUsage, error) {
|
|
records, err := s.repo.RecentCloudAgentEventsForUser(userID, cloudAgentSkillUsageRunWindow)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
events := make([]CloudAgentEvent, 0, len(records))
|
|
for _, record := range records {
|
|
var event CloudAgentEvent
|
|
if err := json.Unmarshal([]byte(record.EventJSON), &event); err != nil {
|
|
// A corrupt receipt makes the aggregate incomplete; do not report it as valid usage.
|
|
return nil, fmt.Errorf("decode Agent skill usage receipt: %w", err)
|
|
}
|
|
events = append(events, event)
|
|
}
|
|
return cloudAgentSkillUsageFromEvents(events), nil
|
|
}
|
|
|
|
// cloudAgentSkillUsageFromEvents aggregates one user's journal into per-skill
|
|
// adoption. It is pure so the attribution rules can be tested without a database.
|
|
func cloudAgentSkillUsageFromEvents(events []CloudAgentEvent) *CloudAgentSkillUsage {
|
|
usage := &CloudAgentSkillUsage{Skills: []CloudAgentSkillUsageEntry{}}
|
|
entries := map[string]*CloudAgentSkillUsageEntry{}
|
|
cardCounts := map[string]map[string]int{}
|
|
scannedRuns := map[string]bool{}
|
|
|
|
entry := func(skillID string) *CloudAgentSkillUsageEntry {
|
|
if existing, ok := entries[skillID]; ok {
|
|
return existing
|
|
}
|
|
created := &CloudAgentSkillUsageEntry{SkillID: skillID}
|
|
entries[skillID] = created
|
|
return created
|
|
}
|
|
|
|
for _, event := range events {
|
|
if event.Type != "tool_completed" && event.Type != "tool_failed" {
|
|
continue
|
|
}
|
|
payload := event.Payload
|
|
name, _ := payload["toolName"].(string)
|
|
switch name {
|
|
case "skills_load":
|
|
scannedRuns[event.RunID] = true
|
|
for _, id := range cloudAgentEventStringSlice(payload["skillIds"]) {
|
|
entry(id).RunsEnabled++
|
|
}
|
|
case "skill_search":
|
|
usage.SearchCalls++
|
|
result, _ := payload["result"].(map[string]any)
|
|
matches, _ := result["matches"].([]any)
|
|
for _, raw := range matches {
|
|
match, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id, _ := match["skillId"].(string)
|
|
if strings.TrimSpace(id) == "" {
|
|
continue
|
|
}
|
|
entry(id).SearchHits++
|
|
}
|
|
case "skill_read_file":
|
|
id, _ := payload["skillId"].(string)
|
|
if strings.TrimSpace(id) == "" {
|
|
continue
|
|
}
|
|
target := entry(id)
|
|
if event.Type == "tool_failed" {
|
|
target.ReadFailures++
|
|
continue
|
|
}
|
|
target.ReadCalls++
|
|
if label := cloudAgentEventString(payload["skillName"]); label != "" {
|
|
target.SkillName = label
|
|
}
|
|
path := cloudAgentEventString(payload["path"])
|
|
if path == "" || path == cloudAgentSkillEntryPath {
|
|
target.EntryReads++
|
|
continue
|
|
}
|
|
target.CardReads++
|
|
if cardCounts[id] == nil {
|
|
cardCounts[id] = map[string]int{}
|
|
}
|
|
cardCounts[id][path]++
|
|
}
|
|
}
|
|
usage.ScannedRuns = len(scannedRuns)
|
|
|
|
for id, target := range entries {
|
|
counts := cardCounts[id]
|
|
if len(counts) == 0 {
|
|
continue
|
|
}
|
|
cards := make([]CloudAgentSkillCardUsage, 0, len(counts))
|
|
for path, count := range counts {
|
|
cards = append(cards, CloudAgentSkillCardUsage{Path: path, Count: count})
|
|
}
|
|
sort.Slice(cards, func(i, j int) bool {
|
|
if cards[i].Count != cards[j].Count {
|
|
return cards[i].Count > cards[j].Count
|
|
}
|
|
return cards[i].Path < cards[j].Path
|
|
})
|
|
if len(cards) > cloudAgentSkillUsageTopCards {
|
|
cards = cards[:cloudAgentSkillUsageTopCards]
|
|
}
|
|
target.TopCards = cards
|
|
}
|
|
|
|
for _, target := range entries {
|
|
usage.Skills = append(usage.Skills, *target)
|
|
}
|
|
// Busiest first: the skills a user actually leans on should not be buried.
|
|
sort.Slice(usage.Skills, func(i, j int) bool {
|
|
left, right := usage.Skills[i], usage.Skills[j]
|
|
if left.RunsEnabled != right.RunsEnabled {
|
|
return left.RunsEnabled > right.RunsEnabled
|
|
}
|
|
if left.ReadCalls != right.ReadCalls {
|
|
return left.ReadCalls > right.ReadCalls
|
|
}
|
|
return left.SkillID < right.SkillID
|
|
})
|
|
return usage
|
|
}
|
|
|
|
func cloudAgentEventString(value any) string {
|
|
text, _ := value.(string)
|
|
return strings.TrimSpace(text)
|
|
}
|
|
|
|
func cloudAgentEventStringSlice(value any) []string {
|
|
raw, ok := value.([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(raw))
|
|
for _, item := range raw {
|
|
if text, ok := item.(string); ok && strings.TrimSpace(text) != "" {
|
|
out = append(out, text)
|
|
}
|
|
}
|
|
return out
|
|
}
|